feat: initial Go/JS/Rust Origin check-in client SDKs
CI / rust (push) Successful in 3m6s
CI / go (push) Successful in 33s
CI / js (push) Successful in 34s

Standalone clients that resolve a machine unique ID (OS machine ID first,
falling back to a generated UUID persisted to disk), detect hostname/IP/
container/orchestrator, and register+re-ping origin.warky.dev's public
service-checkin endpoint on a daily interval. Ported from icy2's internal
origincheckin package but extended to the full check-in contract and made
dependency-light for external reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 15:25:30 +02:00
co-authored by Claude Sonnet 5
parent 282f76a021
commit 2590e404f5
40 changed files with 4105 additions and 181 deletions
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
go:
runs-on: ubuntu-latest
defaults:
run:
working-directory: go
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go/go.mod
- run: go build ./...
- run: go vet ./...
- run: test -z "$(gofmt -l .)"
- run: go test ./...
js:
runs-on: ubuntu-latest
defaults:
run:
working-directory: js
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: js/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: cargo fmt -- --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo build
- run: cargo test
+15 -180
View File
@@ -1,183 +1,18 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Go
go/*.test
go/coverage.out
# Test binary, built with `go test -c`
*.test
# Node
js/node_modules/
js/dist/
js/coverage/
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Rust
rust/target/
Cargo.lock
# Editors/OS
.vscode/
.idea/
*.swp
.DS_Store
+106
View File
@@ -0,0 +1,106 @@
Copyright (c) 2026 Warky Devs Pty Ltd. All rights reserved.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, "control" means (i) the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Object" form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object
code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that is
included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship.
"Contribution" shall mean any work of authorship, including the original
version of the Work and any modifications or additions to that Work or
Derivative Works thereof, that is intentionally submitted to Licensor for
inclusion in the Work by the copyright owner or by an individual or Legal
Entity authorized to submit on behalf of the copyright owner.
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
reproduce, prepare Derivative Works of, publicly display, publicly perform,
sublicense, and distribute the Work and such Derivative Works in Source or
Object form.
3. Grant of Patent License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
section) patent license to make, have made, use, offer to sell, sell, import,
and otherwise transfer the Work.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and in
Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a
copy of this License; and
(b) You must cause any modified files to carry prominent notices stating
that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices
from the Source form of the Work; and
(d) If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable
copy of the attribution notices contained within such NOTICE file.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without any
additional terms or conditions.
6. Trademarks. This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied.
8. Limitation of Liability. In no event and under no legal theory shall any
Contributor be liable to You for damages arising as a result of this License
or out of the use or inability to use the Work.
9. Accepting Warranty or Additional Liability. While redistributing the Work
or Derivative Works thereof, You may choose to offer, and charge a fee for,
acceptance of support, warranty, indemnity, or other liability obligations
and/or rights consistent with this License.
END OF TERMS AND CONDITIONS
+42 -1
View File
@@ -1,3 +1,44 @@
# origin_client
origin client
Client SDKs that register a running service with
[Origin](https://origin.warky.dev)'s public check-in endpoint and re-ping it
on an interval. One package per language, same wire contract, same behavior.
## Packages
| Language | Path | Module/package |
|---|---|---|
| Go | [`go/`](go/) | `git.warky.dev/wdevs/origin_client/go` |
| JavaScript/TypeScript | [`js/`](js/) | `@warkanum/origin-client` |
| Rust | [`rust/`](rust/) | `origin-client` |
## What it does
- Resolves a machine unique ID: real OS ID first (`/etc/machine-id` on
Linux, `IOPlatformUUID` on macOS, `MachineGuid` on Windows), falling back
to a generated UUID persisted to disk.
- Detects hostname, outbound IP, container ID (Docker/cgroup), and
orchestrator (Kubernetes/Docker) best-effort.
- POSTs a check-in payload to Origin immediately, then again every
`interval_hours` (default 24), until stopped. Network/server failures are
logged and swallowed — check-in never crashes or blocks the host app.
## Wire contract
`POST {base_url}` (default `https://origin.warky.dev/api/public/service-checkin`)
with header `X-Origin-Service-Key: <your key>`.
Required: `type`, `version`, `name`, `unique_install_id`.
Optional: `site`, `environment`, `database_type`, `database_name`,
`app_type`, `hostname`, `port`, `base_url`, `internal_url`, `container_id`,
`orchestrator`, `description`.
Response: `success`, `service_instance_id`, `unique_install_id`, `status`,
`pinged_at`.
Full contract: see `origin/doc/service-checkin.md` in the Origin repo.
## Status
Structured for independent versioning/publishing per package. Not yet
published to any registry.
+45
View File
@@ -0,0 +1,45 @@
# originclient (Go)
```
go get git.warky.dev/wdevs/origin_client/go
```
## Usage
```go
c, err := originclient.New(originclient.Config{
ServiceKey: "...",
Type: "myservice",
Version: "1.2.3",
Name: "myservice-nova",
})
if err != nil {
log.Fatal(err)
}
go c.Run(ctx) // immediate check-in, then every 24h until ctx is done
```
Call `c.CheckinOnce(ctx)` directly instead of `Run` to send a single
check-in and observe the error/response.
## Config
| Field | Required | Default |
|---|---|---|
| `ServiceKey` | yes | — |
| `Type`, `Version`, `Name` | yes | — |
| `URL` | no | `DefaultURL` (`https://origin.warky.dev/api/public/service-checkin`) |
| `AppType` | no | `"go"` |
| `Hostname` | no | OS hostname |
| `ContainerID` | no | best-effort Docker/cgroup detection |
| `Orchestrator` | no | best-effort Kubernetes/Docker detection |
| `InstallID` | no | OS machine ID, else a generated UUID persisted to `InstallIDPath` |
| `IntervalHours` | no | `DefaultIntervalHours` (24) |
| `Logger` | no | discards warnings |
`Site`, `Environment`, `DatabaseType`, `DatabaseName`, `Port`, `BaseURL`,
`InternalURL`, `Description` are optional metadata with no default.
`Hostname()`, `OutboundIP()`, `ContainerID()`, `Orchestrator()`, and
`ResolveMachineID()` are exported standalone for callers that want the
detection logic without the HTTP client.
+213
View File
@@ -0,0 +1,213 @@
// Package originclient registers a running service with Origin
// (origin.warky.dev)'s public check-in endpoint and re-pings it on an
// interval, so Origin can track which instances of a service are alive,
// their version, and where they run. A check-in failure is logged and
// otherwise ignored: it must never affect the host application's own
// availability.
package originclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
)
const (
// DefaultURL is Origin's public service-checkin endpoint.
DefaultURL = "https://origin.warky.dev/api/public/service-checkin"
// DefaultIntervalHours is how often Run repeats when Config.IntervalHours is unset.
DefaultIntervalHours = 24
requestTimeout = 10 * time.Second
)
// Config configures a Client. ServiceKey, Type, Version, and Name identify
// the caller to Origin and are required; everything else is optional
// metadata, or overrides for values Client would otherwise auto-detect.
type Config struct {
// URL is the check-in endpoint. Defaults to DefaultURL.
URL string
// ServiceKey is sent as the X-Origin-Service-Key header. Required.
ServiceKey string
// Type is the Origin service type, which must already be registered in
// Origin (e.g. "icy2"). Required.
Type string
// Version is the caller's own version string. Required.
Version string
// Name identifies this deployment/instance to Origin, e.g. "icy2-nova". Required.
Name string
Site string
Environment string
DatabaseType string
DatabaseName string
// AppType defaults to "go" when empty.
AppType string
// Hostname defaults to the OS hostname (see Hostname) when empty.
Hostname string
Port int
BaseURL string
InternalURL string
// ContainerID defaults to best-effort Docker/cgroup detection (see
// ContainerID) when empty.
ContainerID string
// Orchestrator defaults to best-effort Kubernetes/Docker detection (see
// Orchestrator) when empty.
Orchestrator string
Description string
// InstallID, if set, overrides machine-ID resolution entirely.
InstallID string
// InstallIDPath is where a generated fallback install ID is persisted
// when no real OS machine ID is available. Defaults to the OS
// user-config directory (see ResolveMachineID).
InstallIDPath string
// IntervalHours sets how often Run repeats. Defaults to DefaultIntervalHours.
IntervalHours int
// Logger receives non-fatal warnings. Defaults to discarding them.
Logger Logger
}
// Client checks a service in with Origin.
type Client struct {
cfg Config
http *http.Client
installID string
}
// New validates cfg, resolves the machine/install ID, and returns a ready
// Client. Resolution happens once, at construction, so every check-in made
// by this Client reports the same UniqueInstallID.
func New(cfg Config) (*Client, error) {
if cfg.ServiceKey == "" || cfg.Type == "" || cfg.Version == "" || cfg.Name == "" {
return nil, errors.New("originclient: ServiceKey, Type, Version, and Name are required")
}
if cfg.URL == "" {
cfg.URL = DefaultURL
}
if cfg.AppType == "" {
cfg.AppType = "go"
}
if cfg.IntervalHours <= 0 {
cfg.IntervalHours = DefaultIntervalHours
}
if cfg.Logger == nil {
cfg.Logger = noopLogger{}
}
return &Client{
cfg: cfg,
http: &http.Client{Timeout: requestTimeout},
installID: ResolveMachineID(cfg.InstallID, cfg.InstallIDPath, cfg.Logger),
}, nil
}
// Run performs an immediate check-in, then repeats every
// Config.IntervalHours until ctx is done. Run blocks the calling goroutine,
// so callers invoke it with `go client.Run(ctx)`. Check-in errors are
// logged via Config.Logger and otherwise ignored; call CheckinOnce directly
// if the caller needs to observe failures.
func (c *Client) Run(ctx context.Context) {
_, _ = c.CheckinOnce(ctx)
ticker := time.NewTicker(time.Duration(c.cfg.IntervalHours) * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = c.CheckinOnce(ctx)
}
}
}
// CheckinOnce sends a single check-in request and returns Origin's
// response. Failures are logged via Config.Logger and also returned, so
// callers that only want the fire-and-forget behavior of Run can ignore
// the returned error.
func (c *Client) CheckinOnce(ctx context.Context) (*Response, error) {
payload := c.buildPayload()
body, err := json.Marshal(payload)
if err != nil {
c.cfg.Logger.Warn("origin checkin: encode payload failed", err)
return nil, fmt.Errorf("originclient: encode payload: %w", err)
}
reqCtx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.cfg.URL, bytes.NewReader(body))
if err != nil {
c.cfg.Logger.Warn("origin checkin: build request failed", err)
return nil, fmt.Errorf("originclient: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Origin-Service-Key", c.cfg.ServiceKey)
resp, err := c.http.Do(req)
if err != nil {
c.cfg.Logger.Warn("origin checkin: request failed", err)
return nil, fmt.Errorf("originclient: do request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 300 {
err := fmt.Errorf("originclient: non-2xx response: %d", resp.StatusCode)
c.cfg.Logger.Warn("origin checkin: non-2xx response", err)
return nil, err
}
var result Response
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
c.cfg.Logger.Warn("origin checkin: decode response failed", err)
return nil, fmt.Errorf("originclient: decode response: %w", err)
}
return &result, nil
}
func (c *Client) buildPayload() Payload {
hostname := c.cfg.Hostname
if hostname == "" {
hostname = Hostname()
}
containerID := c.cfg.ContainerID
if containerID == "" {
containerID = ContainerID()
}
orchestrator := c.cfg.Orchestrator
if orchestrator == "" {
orchestrator = Orchestrator()
}
return Payload{
Type: c.cfg.Type,
Version: c.cfg.Version,
Name: c.cfg.Name,
UniqueInstallID: c.installID,
Site: c.cfg.Site,
Environment: c.cfg.Environment,
DatabaseType: c.cfg.DatabaseType,
DatabaseName: c.cfg.DatabaseName,
AppType: c.cfg.AppType,
Hostname: hostname,
Port: c.cfg.Port,
BaseURL: c.cfg.BaseURL,
InternalURL: c.cfg.InternalURL,
ContainerID: containerID,
Orchestrator: orchestrator,
Description: c.cfg.Description,
}
}
+148
View File
@@ -0,0 +1,148 @@
package originclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testConfig(url string) Config {
return Config{
URL: url,
ServiceKey: "test-key",
Type: "icy2",
Version: "v1.0.0",
Name: "icy2-test",
Site: "Test Site",
Environment: "test",
IntervalHours: 24,
InstallID: "test-install-id",
}
}
func TestNew_RequiresFields(t *testing.T) {
_, err := New(Config{})
assert.Error(t, err)
}
func TestNew_AppliesDefaults(t *testing.T) {
c, err := New(Config{ServiceKey: "k", Type: "t", Version: "v", Name: "n", InstallID: "id"})
require.NoError(t, err)
assert.Equal(t, DefaultURL, c.cfg.URL)
assert.Equal(t, "go", c.cfg.AppType)
assert.Equal(t, DefaultIntervalHours, c.cfg.IntervalHours)
assert.Equal(t, "id", c.installID)
}
func TestBuildPayload(t *testing.T) {
c, err := New(testConfig("https://example.invalid/checkin"))
require.NoError(t, err)
payload := c.buildPayload()
assert.Equal(t, "icy2", payload.Type)
assert.Equal(t, "go", payload.AppType)
assert.Equal(t, "v1.0.0", payload.Version)
assert.Equal(t, "icy2-test", payload.Name)
assert.Equal(t, "Test Site", payload.Site)
assert.Equal(t, "test", payload.Environment)
assert.Equal(t, "test-install-id", payload.UniqueInstallID)
}
func TestCheckinOnce_Success(t *testing.T) {
var gotKey string
var gotBody Payload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.Header.Get("X-Origin-Service-Key")
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(Response{
Success: true,
ServiceInstanceID: 42,
UniqueInstallID: gotBody.UniqueInstallID,
Status: "provisioning",
PingedAt: "2026-08-01T00:00:00Z",
})
}))
defer server.Close()
c, err := New(testConfig(server.URL))
require.NoError(t, err)
resp, err := c.CheckinOnce(t.Context())
require.NoError(t, err)
assert.Equal(t, "test-key", gotKey)
assert.Equal(t, "icy2", gotBody.Type)
assert.Equal(t, "icy2-test", gotBody.Name)
assert.Equal(t, "test-install-id", gotBody.UniqueInstallID)
assert.True(t, resp.Success)
assert.Equal(t, int64(42), resp.ServiceInstanceID)
}
func TestCheckinOnce_ServerErrorReturnsError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c, err := New(testConfig(server.URL))
require.NoError(t, err)
resp, err := c.CheckinOnce(t.Context())
assert.Error(t, err)
assert.Nil(t, resp)
}
func TestCheckinOnce_UnreachableDoesNotPanic(t *testing.T) {
c, err := New(testConfig("http://127.0.0.1:1"))
require.NoError(t, err)
assert.NotPanics(t, func() {
_, _ = c.CheckinOnce(t.Context())
})
}
func TestCheckinOnce_LogsOnFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
cfg := testConfig(server.URL)
var warned bool
cfg.Logger = LoggerFunc(func(msg string, err error) { warned = true })
c, err := New(cfg)
require.NoError(t, err)
_, _ = c.CheckinOnce(t.Context())
assert.True(t, warned)
}
func TestRun_ChecksInImmediatelyThenStopsOnContextDone(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(Response{Success: true})
}))
defer server.Close()
c, err := New(testConfig(server.URL))
require.NoError(t, err)
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
defer cancel()
c.Run(ctx)
assert.Equal(t, int32(1), calls.Load())
}
+15
View File
@@ -0,0 +1,15 @@
module git.warky.dev/wdevs/origin_client/go
go 1.24
require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.29.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+14
View File
@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+100
View File
@@ -0,0 +1,100 @@
package originclient
import (
"net"
"os"
"strings"
)
// Hostname returns the OS hostname, or "" if it cannot be determined.
func Hostname() string {
h, err := os.Hostname()
if err != nil {
return ""
}
return h
}
// OutboundIP returns the first non-loopback IPv4 address bound to this
// host. It is a LAN-facing address, not the host's public internet
// address, and is not part of Origin's wire contract — it exists so
// callers can populate BaseURL/InternalURL/Description themselves.
func OutboundIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP.IsLoopback() {
continue
}
if ip4 := ipNet.IP.To4(); ip4 != nil {
return ip4.String()
}
}
return ""
}
// ContainerID best-effort detects the Docker/Kubernetes container ID this
// process runs in. It returns "" outside a container or when detection
// fails; an empty container ID is treated as "not containerized" rather
// than an error.
func ContainerID() string {
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
if id := parseCgroupContainerID(data); id != "" {
return id
}
}
if _, err := os.Stat("/.dockerenv"); err == nil {
if h := Hostname(); isContainerIDLike(h) {
return h
}
}
return ""
}
// Orchestrator best-effort detects the orchestration environment:
// "kubernetes" under Kubernetes, "docker" in a plain Docker container, ""
// otherwise.
func Orchestrator() string {
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
return "kubernetes"
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return "docker"
}
return ""
}
// parseCgroupContainerID extracts a container ID from /proc/self/cgroup
// content, handling both cgroup v1 (".../docker/<id>") and cgroup v2
// systemd-scope (".../docker-<id>.scope") layouts.
func parseCgroupContainerID(data []byte) string {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
idx := strings.LastIndex(line, "/")
if idx == -1 {
continue
}
segment := line[idx+1:]
segment = strings.TrimSuffix(segment, ".scope")
segment = strings.TrimPrefix(segment, "docker-")
if isContainerIDLike(segment) {
return segment
}
}
return ""
}
func isContainerIDLike(s string) bool {
if len(s) < 12 || len(s) > 64 {
return false
}
for _, r := range s {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
+57
View File
@@ -0,0 +1,57 @@
package originclient
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseCgroupContainerID(t *testing.T) {
tests := []struct {
name string
data string
want string
}{
{
name: "cgroup v1 docker path",
data: "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n",
want: "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b",
},
{
name: "cgroup v2 systemd scope",
data: "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n",
want: "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b",
},
{
name: "non-container host",
data: "0::/user.slice/user-1000.slice/session-2.scope\n",
want: "",
},
{
name: "empty",
data: "",
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, parseCgroupContainerID([]byte(tc.data)))
})
}
}
func TestIsContainerIDLike(t *testing.T) {
assert.True(t, isContainerIDLike("9f8b7c6d5e4f"))
assert.False(t, isContainerIDLike("short"))
assert.False(t, isContainerIDLike("session-2"))
assert.False(t, isContainerIDLike(""))
}
func TestOrchestrator_Kubernetes(t *testing.T) {
t.Setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
assert.Equal(t, "kubernetes", Orchestrator())
}
func TestHostname_NotEmpty(t *testing.T) {
assert.NotEmpty(t, Hostname())
}
+20
View File
@@ -0,0 +1,20 @@
package originclient
// Logger receives non-fatal warnings from the client (encode/build/request
// failures, machine-id/persistence fallbacks). It intentionally mirrors the
// smallest common subset of structured loggers so callers can adapt zap,
// slog, logrus, or anything else with one line.
type Logger interface {
Warn(msg string, err error)
}
// LoggerFunc adapts a function to Logger.
type LoggerFunc func(msg string, err error)
// Warn implements Logger.
func (f LoggerFunc) Warn(msg string, err error) { f(msg, err) }
// noopLogger discards every warning.
type noopLogger struct{}
func (noopLogger) Warn(string, error) {}
+87
View File
@@ -0,0 +1,87 @@
package originclient
import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
// errMachineIDUnavailable is returned by the OS-specific machineID() when
// no real machine ID could be read.
var errMachineIDUnavailable = errors.New("originclient: OS machine id unavailable")
// machineIDFunc is a variable indirection over the OS-specific machineID()
// so tests can stub OS machine-id lookup without depending on the actual
// host's state.
var machineIDFunc = machineID
// ResolveMachineID returns the unique install ID to report to Origin, in
// priority order:
//
// 1. explicit, if non-empty — a caller-supplied override.
// 2. the real OS machine ID (/etc/machine-id on Linux, IOPlatformUUID on
// macOS, MachineGuid on Windows), if readable.
// 3. the ID already persisted at path, if present.
// 4. a newly generated UUID, which ResolveMachineID attempts to persist to
// path for future runs.
//
// OS-ID and persistence failures are logged as warnings, not returned as
// errors: ResolveMachineID always returns a usable ID, falling back to one
// that only lives for this process if it cannot be read from the OS or
// saved to disk. logger may be nil.
func ResolveMachineID(explicit, path string, logger Logger) string {
if logger == nil {
logger = noopLogger{}
}
if id := strings.TrimSpace(explicit); id != "" {
return id
}
if id, err := machineIDFunc(); err == nil {
if id = strings.TrimSpace(id); id != "" {
return id
}
} else {
logger.Warn("machine id: OS machine id unavailable, falling back to a generated id", err)
}
if path == "" {
path = defaultInstallIDPath()
}
if data, err := os.ReadFile(path); err == nil {
if id := strings.TrimSpace(string(data)); id != "" {
return id
}
}
id := uuid.NewString()
if err := persistInstallID(path, id); err != nil {
logger.Warn("machine id: failed to persist generated id, using an ephemeral id for this run", err)
}
return id
}
func persistInstallID(path, id string) error {
if dir := filepath.Dir(path); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
}
return os.WriteFile(path, []byte(id+"\n"), 0o600)
}
// defaultInstallIDPath returns the fallback-UUID persistence path used when
// Config.InstallIDPath is unset: the OS user-config directory, or the OS
// temp directory if that cannot be determined.
func defaultInstallIDPath() string {
dir, err := os.UserConfigDir()
if err != nil || dir == "" {
dir = os.TempDir()
}
return filepath.Join(dir, "origin-client", "install-id")
}
+29
View File
@@ -0,0 +1,29 @@
//go:build darwin
package originclient
import (
"context"
"os/exec"
"regexp"
"time"
)
var ioregUUIDPattern = regexp.MustCompile(`"IOPlatformUUID" = "([0-9A-Fa-f-]+)"`)
// machineID reads the hardware IOPlatformUUID via ioreg, which is stable
// across reboots and unique per physical/virtual Mac.
func machineID() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output()
if err != nil {
return "", err
}
match := ioregUUIDPattern.FindSubmatch(out)
if match == nil {
return "", errMachineIDUnavailable
}
return string(match[1]), nil
}
+24
View File
@@ -0,0 +1,24 @@
//go:build linux
package originclient
import (
"os"
"strings"
)
// machineID reads the systemd/dbus machine ID, which is stable across
// reboots and unique per Linux install (but not per container, since it is
// typically inherited from the image unless explicitly reset).
func machineID() (string, error) {
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
data, err := os.ReadFile(path)
if err != nil {
continue
}
if id := strings.TrimSpace(string(data)); id != "" {
return id, nil
}
}
return "", errMachineIDUnavailable
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !darwin && !windows
package originclient
// machineID has no known OS-specific implementation on this platform;
// ResolveMachineID falls back to a generated, persisted UUID.
func machineID() (string, error) {
return "", errMachineIDUnavailable
}
+61
View File
@@ -0,0 +1,61 @@
package originclient
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func withStubMachineID(t *testing.T, id string, err error) {
t.Helper()
original := machineIDFunc
machineIDFunc = func() (string, error) { return id, err }
t.Cleanup(func() { machineIDFunc = original })
}
func TestResolveMachineID_ExplicitOverrideWins(t *testing.T) {
withStubMachineID(t, "os-machine-id", nil)
got := ResolveMachineID("explicit-id", filepath.Join(t.TempDir(), "install-id"), nil)
assert.Equal(t, "explicit-id", got)
}
func TestResolveMachineID_UsesOSMachineIDWhenAvailable(t *testing.T) {
withStubMachineID(t, "os-machine-id", nil)
got := ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), nil)
assert.Equal(t, "os-machine-id", got)
}
func TestResolveMachineID_FallsBackAndPersistsWhenOSIDUnavailable(t *testing.T) {
withStubMachineID(t, "", errMachineIDUnavailable)
path := filepath.Join(t.TempDir(), "nested", "install-id")
first := ResolveMachineID("", path, nil)
assert.NotEmpty(t, first)
second := ResolveMachineID("", path, nil)
assert.Equal(t, first, second, "second call should read the persisted id rather than generating a new one")
}
func TestResolveMachineID_NilLoggerDoesNotPanic(t *testing.T) {
withStubMachineID(t, "", errMachineIDUnavailable)
assert.NotPanics(t, func() {
ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), nil)
})
}
func TestResolveMachineID_LogsWarningWhenOSIDUnavailable(t *testing.T) {
withStubMachineID(t, "", errMachineIDUnavailable)
var warned bool
logger := LoggerFunc(func(msg string, err error) { warned = true })
ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), logger)
assert.True(t, warned)
}
func TestPersistInstallID(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "dir", "install-id")
require.NoError(t, persistInstallID(path, "abc-123"))
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
package originclient
import "golang.org/x/sys/windows/registry"
// machineID reads MachineGuid from the registry, which is generated at
// Windows install time and stable across reboots.
func machineID() (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE|registry.WOW64_64KEY)
if err != nil {
return "", err
}
defer key.Close()
value, _, err := key.GetStringValue("MachineGuid")
if err != nil {
return "", err
}
return value, nil
}
+35
View File
@@ -0,0 +1,35 @@
package originclient
// Payload is the JSON body posted to Origin's public service-checkin
// endpoint. Field names and requiredness mirror
// POST /api/public/service-checkin as documented in
// origin/doc/service-checkin.md: Type, Version, Name, and UniqueInstallID
// are required; everything else is optional and, when omitted, does not
// overwrite existing instance data on Origin's side.
type Payload struct {
Type string `json:"type"`
Version string `json:"version"`
Name string `json:"name"`
UniqueInstallID string `json:"unique_install_id"`
Site string `json:"site,omitempty"`
Environment string `json:"environment,omitempty"`
DatabaseType string `json:"database_type,omitempty"`
DatabaseName string `json:"database_name,omitempty"`
AppType string `json:"app_type,omitempty"`
Hostname string `json:"hostname,omitempty"`
Port int `json:"port,omitempty"`
BaseURL string `json:"base_url,omitempty"`
InternalURL string `json:"internal_url,omitempty"`
ContainerID string `json:"container_id,omitempty"`
Orchestrator string `json:"orchestrator,omitempty"`
Description string `json:"description,omitempty"`
}
// Response is returned by Origin after a successful check-in.
type Response struct {
Success bool `json:"success"`
ServiceInstanceID int64 `json:"service_instance_id"`
UniqueInstallID string `json:"unique_install_id"`
Status string `json:"status"`
PingedAt string `json:"pinged_at"`
}
+47
View File
@@ -0,0 +1,47 @@
# @warkanum/origin-client (JS/TS)
```
npm install @warkanum/origin-client
```
Node >=18, ESM only.
## Usage
```ts
import { OriginClient } from "@warkanum/origin-client";
const client = new OriginClient({
serviceKey: "...",
type: "myservice",
version: "1.2.3",
name: "myservice-nova",
});
client.start(); // immediate check-in, then every 24h until stop()
```
Call `client.checkinOnce()` directly instead of `start()` to send a single
check-in and observe the result/error.
## Config
| Field | Required | Default |
|---|---|---|
| `serviceKey` | yes | — |
| `type`, `version`, `name` | yes | — |
| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) |
| `appType` | no | `"javascript"` |
| `hostname` | no | OS hostname |
| `containerId` | no | best-effort Docker/cgroup detection |
| `orchestrator` | no | best-effort Kubernetes/Docker detection |
| `installId` | no | OS machine ID, else a generated UUID persisted to `installIdPath` |
| `intervalHours` | no | `DEFAULT_INTERVAL_HOURS` (24) |
| `logger` | no | discards warnings |
`site`, `environment`, `databaseType`, `databaseName`, `port`, `baseUrl`,
`internalUrl`, `description` are optional metadata with no default.
`hostname()`, `outboundIp()`, `containerId()`, `orchestrator()`, and
`resolveMachineId()` are exported standalone for callers that want the
detection logic without the HTTP client.
+1331
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@warkanum/origin-client",
"version": "0.1.0",
"description": "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval.",
"type": "module",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0",
"vitest": "^4.1.10"
}
}
+160
View File
@@ -0,0 +1,160 @@
import { containerId as detectContainerId, hostname as detectHostname, orchestrator as detectOrchestrator } from "./hostInfo.js";
import type { Logger } from "./machineId.js";
import { resolveMachineId } from "./machineId.js";
import type { CheckinResponse, Payload } from "./payload.js";
export const DEFAULT_URL = "https://origin.warky.dev/api/public/service-checkin";
export const DEFAULT_INTERVAL_HOURS = 24;
const REQUEST_TIMEOUT_MS = 10_000;
export interface Config {
/** Check-in endpoint. Defaults to DEFAULT_URL. */
url?: string;
/** Sent as the X-Origin-Service-Key header. Required. */
serviceKey: string;
/** Origin service type, which must already be registered in Origin (e.g. "icy2"). Required. */
type: string;
/** Caller's own version string. Required. */
version: string;
/** Identifies this deployment/instance to Origin, e.g. "icy2-nova". Required. */
name: string;
site?: string;
environment?: string;
databaseType?: string;
databaseName?: string;
/** Defaults to "javascript" when unset. */
appType?: string;
/** Defaults to the OS hostname when unset. */
hostname?: string;
port?: number;
baseUrl?: string;
internalUrl?: string;
/** Defaults to best-effort Docker/cgroup detection when unset. */
containerId?: string;
/** Defaults to best-effort Kubernetes/Docker detection when unset. */
orchestrator?: string;
description?: string;
/** Overrides machine-ID resolution entirely when set. */
installId?: string;
/** Where a generated fallback install ID is persisted when no real OS machine ID is available. */
installIdPath?: string;
/** How often start() repeats. Defaults to DEFAULT_INTERVAL_HOURS. */
intervalHours?: number;
/** Receives non-fatal warnings. Defaults to discarding them. */
logger?: Logger;
}
type ResolvedConfig = Config & { url: string; appType: string; intervalHours: number; logger: Logger };
/** OriginClient checks a service in with Origin. */
export class OriginClient {
private readonly cfg: ResolvedConfig;
private readonly installId: string;
private timer?: ReturnType<typeof setInterval>;
/**
* Validates config, resolves the machine/install ID, and returns a ready
* client. Resolution happens once, at construction, so every check-in
* made by this client reports the same unique_install_id.
*/
constructor(config: Config) {
if (!config.serviceKey || !config.type || !config.version || !config.name) {
throw new Error("originclient: serviceKey, type, version, and name are required");
}
const logger = config.logger ?? { warn: () => {} };
this.cfg = {
...config,
url: config.url || DEFAULT_URL,
appType: config.appType || "javascript",
intervalHours: config.intervalHours && config.intervalHours > 0 ? config.intervalHours : DEFAULT_INTERVAL_HOURS,
logger,
};
this.installId = resolveMachineId(config.installId, config.installIdPath, logger);
}
private buildPayload(): Payload {
const hostname = this.cfg.hostname || detectHostname();
const containerId = this.cfg.containerId || detectContainerId();
const orchestrator = this.cfg.orchestrator || detectOrchestrator();
return {
type: this.cfg.type,
version: this.cfg.version,
name: this.cfg.name,
unique_install_id: this.installId,
site: this.cfg.site,
environment: this.cfg.environment,
database_type: this.cfg.databaseType,
database_name: this.cfg.databaseName,
app_type: this.cfg.appType,
hostname: hostname || undefined,
port: this.cfg.port,
base_url: this.cfg.baseUrl,
internal_url: this.cfg.internalUrl,
container_id: containerId || undefined,
orchestrator: orchestrator || undefined,
description: this.cfg.description,
};
}
/**
* Sends a single check-in request and returns Origin's response. Throws
* on any encode/network/non-2xx failure, after logging it via
* config.logger.
*/
async checkinOnce(): Promise<CheckinResponse> {
const payload = this.buildPayload();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(this.cfg.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Origin-Service-Key": this.cfg.serviceKey,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`originclient: non-2xx response: ${res.status}`);
}
return (await res.json()) as CheckinResponse;
} catch (err) {
this.cfg.logger.warn("origin checkin: request failed", err);
throw err;
} finally {
clearTimeout(timeout);
}
}
/**
* Performs an immediate check-in, then repeats every intervalHours until
* stop() is called. Check-in failures are logged via config.logger and
* otherwise ignored; call checkinOnce() directly to observe them.
*/
start(): void {
void this.checkinOnce().catch(() => {});
const intervalMs = this.cfg.intervalHours * 60 * 60 * 1000;
this.timer = setInterval(() => {
void this.checkinOnce().catch(() => {});
}, intervalMs);
this.timer.unref?.();
}
/** Stops the interval started by start(). Safe to call even if start() was never called. */
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
import { existsSync, readFileSync } from "node:fs";
import { hostname as osHostname, networkInterfaces } from "node:os";
/** hostname returns the OS hostname, or "" if it cannot be determined. */
export function hostname(): string {
try {
return osHostname();
} catch {
return "";
}
}
/**
* outboundIp returns the first non-loopback IPv4 address bound to this
* host. It is a LAN-facing address, not the host's public internet
* address, and is not part of Origin's wire contract - it exists so
* callers can populate base_url/internal_url/description themselves.
*/
export function outboundIp(): string {
const interfaces = networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const info of interfaces[name] ?? []) {
if (info.family === "IPv4" && !info.internal) {
return info.address;
}
}
}
return "";
}
/**
* containerId best-effort detects the Docker/Kubernetes container ID this
* process runs in. It returns "" outside a container or when detection
* fails; an empty container ID is treated as "not containerized" rather
* than an error.
*/
export function containerId(): string {
try {
if (existsSync("/proc/self/cgroup")) {
const id = parseCgroupContainerId(readFileSync("/proc/self/cgroup", "utf8"));
if (id) return id;
}
} catch {
// fall through to dockerenv check
}
if (existsSync("/.dockerenv")) {
const h = hostname();
if (isContainerIdLike(h)) return h;
}
return "";
}
/**
* orchestrator best-effort detects the orchestration environment:
* "kubernetes" under Kubernetes, "docker" in a plain Docker container, ""
* otherwise.
*/
export function orchestrator(): string {
if (process.env.KUBERNETES_SERVICE_HOST) return "kubernetes";
if (existsSync("/.dockerenv")) return "docker";
return "";
}
/**
* parseCgroupContainerId extracts a container ID from /proc/self/cgroup
* content, handling both cgroup v1 (".../docker/<id>") and cgroup v2
* systemd-scope (".../docker-<id>.scope") layouts.
*/
export function parseCgroupContainerId(data: string): string {
for (const rawLine of data.split("\n")) {
const line = rawLine.trim();
const idx = line.lastIndexOf("/");
if (idx === -1) continue;
let segment = line.slice(idx + 1);
segment = segment.replace(/\.scope$/, "");
segment = segment.replace(/^docker-/, "");
if (isContainerIdLike(segment)) return segment;
}
return "";
}
export function isContainerIdLike(s: string): boolean {
if (s.length < 12 || s.length > 64) return false;
return /^[0-9a-f]+$/.test(s);
}
+6
View File
@@ -0,0 +1,6 @@
export { DEFAULT_INTERVAL_HOURS, DEFAULT_URL, OriginClient } from "./client.js";
export type { Config } from "./client.js";
export { containerId, hostname, orchestrator, outboundIp } from "./hostInfo.js";
export { defaultInstallIdPath, detectOsMachineId, resolveMachineId } from "./machineId.js";
export type { Logger } from "./machineId.js";
export type { CheckinResponse, Payload } from "./payload.js";
+124
View File
@@ -0,0 +1,124 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, platform, tmpdir } from "node:os";
import { dirname, join } from "node:path";
export interface Logger {
warn(msg: string, err?: unknown): void;
}
const noopLogger: Logger = { warn: () => {} };
function readLinuxMachineId(): string | undefined {
for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
try {
const id = readFileSync(path, "utf8").trim();
if (id) return id;
} catch {
// try the next candidate path
}
}
return undefined;
}
function readDarwinMachineId(): string | undefined {
try {
const out = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
encoding: "utf8",
timeout: 2000,
});
return out.match(/"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"/)?.[1];
} catch {
return undefined;
}
}
function readWindowsMachineId(): string | undefined {
try {
const out = execFileSync(
"reg",
["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
{ encoding: "utf8", timeout: 2000 },
);
return out.match(/MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]+)/)?.[1];
} catch {
return undefined;
}
}
/** detectOsMachineId reads the real OS machine ID, or undefined if unavailable/unsupported. */
export function detectOsMachineId(): string | undefined {
switch (platform()) {
case "linux":
return readLinuxMachineId();
case "darwin":
return readDarwinMachineId();
case "win32":
return readWindowsMachineId();
default:
return undefined;
}
}
/**
* machineIdDetector is a mutable indirection over detectOsMachineId so
* tests can stub OS machine-id lookup without depending on the actual
* host's state. It's a settable-property holder rather than a plain
* exported `let` because ES module namespace bindings can't be reassigned
* from outside the module.
*/
export const machineIdDetector: { current: () => string | undefined } = {
current: detectOsMachineId,
};
/** defaultInstallIdPath returns the fallback-UUID persistence path used when no explicit path is given. */
export function defaultInstallIdPath(): string {
const base = process.env.XDG_CONFIG_HOME || join(homedir() || tmpdir(), ".config");
return join(base, "origin-client", "install-id");
}
/**
* resolveMachineId returns the unique install ID to report to Origin, in
* priority order:
*
* 1. explicit, if non-empty - a caller-supplied override.
* 2. the real OS machine ID (/etc/machine-id on Linux, IOPlatformUUID on
* macOS, MachineGuid on Windows), if readable.
* 3. the ID already persisted at path, if present.
* 4. a newly generated UUID, which resolveMachineId attempts to persist
* to path for future runs.
*
* OS-ID and persistence failures are logged as warnings, not thrown:
* resolveMachineId always returns a usable ID.
*/
export function resolveMachineId(explicit?: string, path?: string, logger: Logger = noopLogger): string {
const trimmedExplicit = explicit?.trim();
if (trimmedExplicit) return trimmedExplicit;
const osId = machineIdDetector.current();
if (osId) {
const trimmed = osId.trim();
if (trimmed) return trimmed;
} else {
logger.warn("machine id: OS machine id unavailable, falling back to a generated id");
}
const resolvedPath = path && path.length > 0 ? path : defaultInstallIdPath();
try {
const existing = readFileSync(resolvedPath, "utf8").trim();
if (existing) return existing;
} catch {
// no persisted id yet
}
const id = randomUUID();
try {
mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o750 });
writeFileSync(resolvedPath, id + "\n", { mode: 0o600 });
} catch (err) {
logger.warn("machine id: failed to persist generated id, using an ephemeral id for this run", err);
}
return id;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Payload is the JSON body posted to Origin's public service-checkin
* endpoint. Field names and requiredness mirror
* POST /api/public/service-checkin as documented in
* origin/doc/service-checkin.md: type, version, name, and
* unique_install_id are required; everything else is optional and, when
* omitted, does not overwrite existing instance data on Origin's side.
*/
export interface Payload {
type: string;
version: string;
name: string;
unique_install_id: string;
site?: string;
environment?: string;
database_type?: string;
database_name?: string;
app_type?: string;
hostname?: string;
port?: number;
base_url?: string;
internal_url?: string;
container_id?: string;
orchestrator?: string;
description?: string;
}
/** CheckinResponse is returned by Origin after a successful check-in. */
export interface CheckinResponse {
success: boolean;
service_instance_id: number;
unique_install_id: string;
status: string;
pinged_at: string;
}
+141
View File
@@ -0,0 +1,141 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { describe, expect, it } from "vitest";
import { OriginClient } from "../src/client.js";
function withServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ server: Server; url: string }> {
return new Promise((resolve) => {
const server = createServer(handler);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
resolve({ server, url: `http://127.0.0.1:${port}` });
});
});
}
describe("OriginClient", () => {
it("requires serviceKey/type/version/name", () => {
expect(() => new OriginClient({ serviceKey: "", type: "", version: "", name: "" })).toThrow();
});
it("sends the expected payload and headers on checkinOnce", async () => {
let gotKey = "";
let gotBody: Record<string, unknown> = {};
const { server, url } = await withServer((req, res) => {
gotKey = req.headers["x-origin-service-key"] as string;
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
});
req.on("end", () => {
gotBody = JSON.parse(raw);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: true,
service_instance_id: 42,
unique_install_id: gotBody.unique_install_id,
status: "provisioning",
pinged_at: "2026-08-01T00:00:00Z",
}),
);
});
});
try {
const client = new OriginClient({
url,
serviceKey: "test-key",
type: "icy2",
version: "v1.0.0",
name: "icy2-test",
installId: "test-install-id",
});
const resp = await client.checkinOnce();
expect(gotKey).toBe("test-key");
expect(gotBody.type).toBe("icy2");
expect(gotBody.name).toBe("icy2-test");
expect(gotBody.unique_install_id).toBe("test-install-id");
expect(resp.success).toBe(true);
expect(resp.service_instance_id).toBe(42);
} finally {
server.close();
}
});
it("throws on a non-2xx response", async () => {
const { server, url } = await withServer((_req, res) => {
res.writeHead(500);
res.end();
});
try {
const client = new OriginClient({
url,
serviceKey: "test-key",
type: "icy2",
version: "v1.0.0",
name: "icy2-test",
installId: "test-install-id",
});
await expect(client.checkinOnce()).rejects.toThrow();
} finally {
server.close();
}
});
it("does not throw for an unreachable server", async () => {
const client = new OriginClient({
url: "http://127.0.0.1:1",
serviceKey: "test-key",
type: "icy2",
version: "v1.0.0",
name: "icy2-test",
installId: "test-install-id",
});
await expect(client.checkinOnce()).rejects.toThrow();
});
it("start() checks in immediately and stop() prevents further calls", async () => {
let calls = 0;
const { server, url } = await withServer((_req, res) => {
calls += 1;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: true,
service_instance_id: 1,
unique_install_id: "x",
status: "provisioning",
pinged_at: "2026-08-01T00:00:00Z",
}),
);
});
try {
const client = new OriginClient({
url,
serviceKey: "test-key",
type: "icy2",
version: "v1.0.0",
name: "icy2-test",
installId: "test-install-id",
intervalHours: 24,
});
client.start();
await new Promise((resolve) => setTimeout(resolve, 50));
client.stop();
expect(calls).toBe(1);
} finally {
server.close();
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { hostname, isContainerIdLike, parseCgroupContainerId } from "../src/hostInfo.js";
describe("parseCgroupContainerId", () => {
it("parses a cgroup v1 docker path", () => {
const data = "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n";
expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b");
});
it("parses a cgroup v2 systemd scope", () => {
const data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n";
expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b");
});
it("returns empty on a non-container host", () => {
expect(parseCgroupContainerId("0::/user.slice/user-1000.slice/session-2.scope\n")).toBe("");
});
it("returns empty for empty input", () => {
expect(parseCgroupContainerId("")).toBe("");
});
});
describe("isContainerIdLike", () => {
it("accepts a hex id of valid length", () => {
expect(isContainerIdLike("9f8b7c6d5e4f")).toBe(true);
});
it("rejects short strings", () => {
expect(isContainerIdLike("short")).toBe(false);
});
it("rejects non-hex strings", () => {
expect(isContainerIdLike("session-2")).toBe(false);
});
it("rejects empty strings", () => {
expect(isContainerIdLike("")).toBe(false);
});
});
describe("hostname", () => {
it("returns a non-empty value", () => {
expect(hostname().length).toBeGreaterThan(0);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as machineId from "../src/machineId.js";
function tempInstallIdPath(): string {
const dir = mkdtempSync(join(tmpdir(), "origin-client-test-"));
return join(dir, "install-id");
}
describe("resolveMachineId", () => {
afterEach(() => {
machineId.machineIdDetector.current = machineId.detectOsMachineId;
});
it("prefers an explicit override", () => {
machineId.machineIdDetector.current = () => "os-machine-id";
expect(machineId.resolveMachineId("explicit-id", tempInstallIdPath())).toBe("explicit-id");
});
it("uses the OS machine id when available", () => {
machineId.machineIdDetector.current = () => "os-machine-id";
expect(machineId.resolveMachineId(undefined, tempInstallIdPath())).toBe("os-machine-id");
});
it("falls back to a generated id and persists it across calls", () => {
machineId.machineIdDetector.current = () => undefined;
const path = tempInstallIdPath();
const first = machineId.resolveMachineId(undefined, path);
expect(first.length).toBeGreaterThan(0);
const second = machineId.resolveMachineId(undefined, path);
expect(second).toBe(first);
});
it("logs a warning when the OS id is unavailable", () => {
machineId.machineIdDetector.current = () => undefined;
const warn = vi.fn();
machineId.resolveMachineId(undefined, tempInstallIdPath(), { warn });
expect(warn).toHaveBeenCalled();
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "origin-client"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval."
repository = "https://git.warky.dev/wdevs/origin_client"
[dependencies]
dirs = "5"
gethostname = "0.5"
if-addrs = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "macros", "sync"] }
tokio-util = "0.7"
uuid = { version = "1", features = ["v4"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
[dev-dependencies]
mockito = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
+61
View File
@@ -0,0 +1,61 @@
# origin-client (Rust)
```
cargo add origin-client
```
Async, built on `tokio` + `reqwest` (rustls, no OpenSSL dependency).
## Usage
```rust
use origin_client::{Client, Config};
use tokio_util::sync::CancellationToken;
let client = Client::new(Config {
service_key: "...".into(),
type_: "myservice".into(),
version: "1.2.3".into(),
name: "myservice-nova".into(),
..Default::default()
})?;
let cancel = CancellationToken::new();
tokio::spawn({
let cancel = cancel.clone();
async move { client.run(cancel).await }
}); // immediate check-in, then every 24h until cancel.cancel() is called
```
Call `client.checkin_once().await` directly instead of `run` to send a
single check-in and observe the result/error.
## Config
| Field | Required | Default |
|---|---|---|
| `service_key` | yes | — |
| `type_`, `version`, `name` | yes | — |
| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) |
| `app_type` | no | `"rust"` |
| `hostname` | no | OS hostname |
| `container_id` | no | best-effort Docker/cgroup detection |
| `orchestrator` | no | best-effort Kubernetes/Docker detection |
| `install_id` | no | OS machine ID, else a generated UUID persisted to `install_id_path` |
| `interval_hours` | no | `DEFAULT_INTERVAL_HOURS` (24) |
| `logger` | no | discards warnings (`NoopLogger`) |
`site`, `environment`, `database_type`, `database_name`, `port`,
`base_url`, `internal_url`, `description` are optional metadata with no
default. `Config` implements `Default`, so use struct-update syntax
(`..Default::default()`) as shown above.
`hostname()`, `outbound_ip()`, `container_id()`, `orchestrator()`, and
`resolve_machine_id()` are exported standalone for callers that want the
detection logic without the HTTP client.
## Platform notes
macOS/Windows machine-ID detection is compiled but not exercised by CI
(this repo's CI runs on Linux). `cargo build --target ...` for those
targets is a reasonable spot-check before release.
+284
View File
@@ -0,0 +1,284 @@
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::host_info::{container_id, hostname, orchestrator};
use crate::logger::{Logger, NoopLogger};
use crate::machine_id::resolve_machine_id;
use crate::payload::{CheckinResponse, Payload};
/// Origin's public service-checkin endpoint.
pub const DEFAULT_URL: &str = "https://origin.warky.dev/api/public/service-checkin";
/// How often `Client::run` repeats when `Config::interval_hours` is unset.
pub const DEFAULT_INTERVAL_HOURS: u32 = 24;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Config configures a Client. `service_key`, `type_`, `version`, and
/// `name` identify the caller to Origin and are required; everything else
/// is optional metadata, or overrides for values Client would otherwise
/// auto-detect.
#[derive(Clone)]
pub struct Config {
/// Check-in endpoint. Defaults to [`DEFAULT_URL`] when `None`.
pub url: Option<String>,
/// Sent as the `X-Origin-Service-Key` header. Required.
pub service_key: String,
/// Origin service type, which must already be registered in Origin
/// (e.g. `"icy2"`). Required.
pub type_: String,
/// Caller's own version string. Required.
pub version: String,
/// Identifies this deployment/instance to Origin, e.g. `"icy2-nova"`. Required.
pub name: String,
pub site: Option<String>,
pub environment: Option<String>,
pub database_type: Option<String>,
pub database_name: Option<String>,
/// Defaults to `"rust"` when `None`.
pub app_type: Option<String>,
/// Defaults to the OS hostname when `None`.
pub hostname: Option<String>,
pub port: Option<u16>,
pub base_url: Option<String>,
pub internal_url: Option<String>,
/// Defaults to best-effort Docker/cgroup detection when `None`.
pub container_id: Option<String>,
/// Defaults to best-effort Kubernetes/Docker detection when `None`.
pub orchestrator: Option<String>,
pub description: Option<String>,
/// Overrides machine-ID resolution entirely when set.
pub install_id: Option<String>,
/// Where a generated fallback install ID is persisted when no real OS
/// machine ID is available.
pub install_id_path: Option<PathBuf>,
/// How often `run` repeats. Defaults to [`DEFAULT_INTERVAL_HOURS`] when `None`.
pub interval_hours: Option<u32>,
/// Receives non-fatal warnings. Defaults to discarding them.
pub logger: Arc<dyn Logger>,
}
impl Default for Config {
fn default() -> Self {
Self {
url: None,
service_key: String::new(),
type_: String::new(),
version: String::new(),
name: String::new(),
site: None,
environment: None,
database_type: None,
database_name: None,
app_type: None,
hostname: None,
port: None,
base_url: None,
internal_url: None,
container_id: None,
orchestrator: None,
description: None,
install_id: None,
install_id_path: None,
interval_hours: None,
logger: Arc::new(NoopLogger),
}
}
}
/// Error returned by [`Client::new`] and [`Client::checkin_once`].
#[derive(Debug)]
pub enum Error {
MissingRequiredConfig,
BuildClient(reqwest::Error),
Request(reqwest::Error),
Status(reqwest::StatusCode),
Decode(reqwest::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::MissingRequiredConfig => {
write!(
f,
"origin-client: service_key, type, version, and name are required"
)
}
Error::BuildClient(e) => write!(f, "origin-client: build http client: {e}"),
Error::Request(e) => write!(f, "origin-client: request failed: {e}"),
Error::Status(code) => write!(f, "origin-client: non-2xx response: {code}"),
Error::Decode(e) => write!(f, "origin-client: decode response: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::BuildClient(e) | Error::Request(e) | Error::Decode(e) => Some(e),
Error::MissingRequiredConfig | Error::Status(_) => None,
}
}
}
/// Client checks a service in with Origin.
pub struct Client {
cfg: Config,
http: reqwest::Client,
install_id: String,
}
impl Client {
/// Validates `cfg`, resolves the machine/install ID, and returns a
/// ready Client. Resolution happens once, at construction, so every
/// check-in made by this Client reports the same `unique_install_id`.
pub fn new(cfg: Config) -> Result<Self, Error> {
if cfg.service_key.is_empty()
|| cfg.type_.is_empty()
|| cfg.version.is_empty()
|| cfg.name.is_empty()
{
return Err(Error::MissingRequiredConfig);
}
let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(Error::BuildClient)?;
let install_id = resolve_machine_id(
cfg.install_id.as_deref(),
cfg.install_id_path.as_deref(),
cfg.logger.as_ref(),
);
Ok(Self {
cfg,
http,
install_id,
})
}
fn url(&self) -> &str {
self.cfg.url.as_deref().unwrap_or(DEFAULT_URL)
}
fn app_type(&self) -> String {
self.cfg
.app_type
.clone()
.unwrap_or_else(|| "rust".to_string())
}
fn interval_hours(&self) -> u32 {
self.cfg
.interval_hours
.filter(|&h| h > 0)
.unwrap_or(DEFAULT_INTERVAL_HOURS)
}
fn build_payload(&self) -> Payload {
let hostname_value = self.cfg.hostname.clone().unwrap_or_else(hostname);
let container_id_value = self.cfg.container_id.clone().unwrap_or_else(container_id);
let orchestrator_value = self.cfg.orchestrator.clone().unwrap_or_else(orchestrator);
Payload {
type_: self.cfg.type_.clone(),
version: self.cfg.version.clone(),
name: self.cfg.name.clone(),
unique_install_id: self.install_id.clone(),
site: self.cfg.site.clone(),
environment: self.cfg.environment.clone(),
database_type: self.cfg.database_type.clone(),
database_name: self.cfg.database_name.clone(),
app_type: Some(self.app_type()),
hostname: non_empty(hostname_value),
port: self.cfg.port,
base_url: self.cfg.base_url.clone(),
internal_url: self.cfg.internal_url.clone(),
container_id: non_empty(container_id_value),
orchestrator: non_empty(orchestrator_value),
description: self.cfg.description.clone(),
}
}
/// Sends a single check-in request and returns Origin's response.
/// Failures are logged via `Config::logger` and also returned, so
/// callers that only want the fire-and-forget behavior of `run` can
/// ignore the returned error.
pub async fn checkin_once(&self) -> Result<CheckinResponse, Error> {
let payload = self.build_payload();
let response = match self
.http
.post(self.url())
.header("X-Origin-Service-Key", &self.cfg.service_key)
.json(&payload)
.send()
.await
{
Ok(r) => r,
Err(err) => {
self.cfg
.logger
.warn("origin checkin: request failed", Some(&err));
return Err(Error::Request(err));
}
};
if !response.status().is_success() {
let status = response.status();
self.cfg
.logger
.warn("origin checkin: non-2xx response", None);
return Err(Error::Status(status));
}
match response.json::<CheckinResponse>().await {
Ok(body) => Ok(body),
Err(err) => {
self.cfg
.logger
.warn("origin checkin: decode response failed", Some(&err));
Err(Error::Decode(err))
}
}
}
/// Performs an immediate check-in, then repeats every
/// `Config::interval_hours` until `cancel` is cancelled. Intended to be
/// spawned: `tokio::spawn(async move { client.run(cancel).await })`.
/// Check-in errors are logged via `Config::logger` and otherwise
/// ignored; call [`Client::checkin_once`] directly to observe them.
pub async fn run(&self, cancel: CancellationToken) {
let _ = self.checkin_once().await;
let interval = Duration::from_secs(u64::from(self.interval_hours()) * 3600);
loop {
tokio::select! {
() = tokio::time::sleep(interval) => {
let _ = self.checkin_once().await;
}
() = cancel.cancelled() => {
return;
}
}
}
}
}
fn non_empty(s: String) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s)
}
}
+154
View File
@@ -0,0 +1,154 @@
use std::env;
use std::fs;
use std::net::IpAddr;
use std::path::Path;
/// Returns the OS hostname, or "" if it cannot be determined.
pub fn hostname() -> String {
gethostname::gethostname().to_string_lossy().into_owned()
}
/// Returns the first non-loopback IPv4 address bound to this host. It is a
/// LAN-facing address, not the host's public internet address, and is not
/// part of Origin's wire contract - it exists so callers can populate
/// `base_url`/`internal_url`/`description` themselves.
pub fn outbound_ip() -> String {
if_addrs::get_if_addrs()
.unwrap_or_default()
.into_iter()
.filter(|iface| !iface.is_loopback())
.find_map(|iface| match iface.ip() {
IpAddr::V4(v4) => Some(v4.to_string()),
IpAddr::V6(_) => None,
})
.unwrap_or_default()
}
/// Best-effort detects the Docker/Kubernetes container ID this process
/// runs in. Returns "" outside a container or when detection fails; an
/// empty container ID is treated as "not containerized" rather than an
/// error.
pub fn container_id() -> String {
if let Ok(data) = fs::read_to_string("/proc/self/cgroup") {
let id = parse_cgroup_container_id(&data);
if !id.is_empty() {
return id;
}
}
if Path::new("/.dockerenv").exists() {
let h = hostname();
if is_container_id_like(&h) {
return h;
}
}
String::new()
}
/// Best-effort detects the orchestration environment: "kubernetes" under
/// Kubernetes, "docker" in a plain Docker container, "" otherwise.
pub fn orchestrator() -> String {
if env::var("KUBERNETES_SERVICE_HOST").is_ok_and(|v| !v.is_empty()) {
return "kubernetes".to_string();
}
if Path::new("/.dockerenv").exists() {
return "docker".to_string();
}
String::new()
}
/// Extracts a container ID from /proc/self/cgroup content, handling both
/// cgroup v1 (".../docker/<id>") and cgroup v2 systemd-scope
/// (".../docker-<id>.scope") layouts.
pub fn parse_cgroup_container_id(data: &str) -> String {
for raw_line in data.lines() {
let line = raw_line.trim();
let idx = match line.rfind('/') {
Some(i) => i,
None => continue,
};
let mut segment = &line[idx + 1..];
segment = segment.strip_suffix(".scope").unwrap_or(segment);
segment = segment.strip_prefix("docker-").unwrap_or(segment);
if is_container_id_like(segment) {
return segment.to_string();
}
}
String::new()
}
pub fn is_container_id_like(s: &str) -> bool {
if s.len() < 12 || s.len() > 64 {
return false;
}
s.chars()
.all(|c| c.is_ascii_digit() || (c.is_ascii_lowercase() && c.is_ascii_hexdigit()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_cgroup_v1_docker_path() {
let data =
"12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n";
assert_eq!(
parse_cgroup_container_id(data),
"9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"
);
}
#[test]
fn parses_cgroup_v2_systemd_scope() {
let data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n";
assert_eq!(
parse_cgroup_container_id(data),
"9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"
);
}
#[test]
fn returns_empty_on_non_container_host() {
assert_eq!(
parse_cgroup_container_id("0::/user.slice/user-1000.slice/session-2.scope\n"),
""
);
}
#[test]
fn returns_empty_for_empty_input() {
assert_eq!(parse_cgroup_container_id(""), "");
}
#[test]
fn is_container_id_like_accepts_valid_hex_id() {
assert!(is_container_id_like("9f8b7c6d5e4f"));
}
#[test]
fn is_container_id_like_rejects_short_strings() {
assert!(!is_container_id_like("short"));
}
#[test]
fn is_container_id_like_rejects_non_hex_strings() {
assert!(!is_container_id_like("session-2"));
}
#[test]
fn is_container_id_like_rejects_empty_strings() {
assert!(!is_container_id_like(""));
}
#[test]
fn hostname_is_not_empty() {
assert!(!hostname().is_empty());
}
#[test]
fn orchestrator_detects_kubernetes() {
std::env::set_var("KUBERNETES_SERVICE_HOST", "10.0.0.1");
assert_eq!(orchestrator(), "kubernetes");
std::env::remove_var("KUBERNETES_SERVICE_HOST");
}
}
+18
View File
@@ -0,0 +1,18 @@
//! origin-client registers a running service with Origin
//! (origin.warky.dev)'s public check-in endpoint and re-pings it on an
//! interval, so Origin can track which instances of a service are alive,
//! their version, and where they run. A check-in failure is logged and
//! otherwise ignored: it must never affect the host application's own
//! availability.
mod client;
mod host_info;
mod logger;
mod machine_id;
mod payload;
pub use client::{Client, Config, Error, DEFAULT_INTERVAL_HOURS, DEFAULT_URL};
pub use host_info::{container_id, hostname, orchestrator, outbound_ip};
pub use logger::{FnLogger, Logger, NoopLogger};
pub use machine_id::{default_install_id_path, detect_os_machine_id, resolve_machine_id};
pub use payload::{CheckinResponse, Payload};
+30
View File
@@ -0,0 +1,30 @@
use std::error::Error as StdError;
/// Logger receives non-fatal warnings from the client (request failures,
/// machine-id/persistence fallbacks). Implement it to route warnings into
/// your own logging framework.
pub trait Logger: Send + Sync {
fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>);
}
/// NoopLogger discards every warning. The default when no logger is configured.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopLogger;
impl Logger for NoopLogger {
fn warn(&self, _msg: &str, _err: Option<&(dyn StdError + Send + Sync)>) {}
}
/// FnLogger adapts a closure to Logger.
pub struct FnLogger<F>(pub F)
where
F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync;
impl<F> Logger for FnLogger<F>
where
F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync,
{
fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>) {
(self.0)(msg, err)
}
}
+201
View File
@@ -0,0 +1,201 @@
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::logger::Logger;
/// Reads the real OS machine ID, or `None` if unavailable/unsupported.
#[cfg(target_os = "linux")]
pub fn detect_os_machine_id() -> Option<String> {
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(data) = fs::read_to_string(path) {
let id = data.trim();
if !id.is_empty() {
return Some(id.to_string());
}
}
}
None
}
/// Reads the hardware IOPlatformUUID via `ioreg`, which is stable across
/// reboots and unique per physical/virtual Mac.
#[cfg(target_os = "macos")]
pub fn detect_os_machine_id() -> Option<String> {
use std::process::Command;
let output = Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let marker = "\"IOPlatformUUID\" = \"";
let start = text.find(marker)? + marker.len();
let rest = &text[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
/// Reads `MachineGuid` from the registry, which is generated at Windows
/// install time and stable across reboots.
#[cfg(target_os = "windows")]
pub fn detect_os_machine_id() -> Option<String> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let key = hklm.open_subkey("SOFTWARE\\Microsoft\\Cryptography").ok()?;
key.get_value("MachineGuid").ok()
}
/// No known OS-specific implementation on this platform; `resolve_machine_id`
/// falls back to a generated, persisted UUID.
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub fn detect_os_machine_id() -> Option<String> {
None
}
/// Returns the fallback-UUID persistence path used when no explicit path
/// is given: the OS user-config directory, or the OS temp directory if
/// that cannot be determined.
pub fn default_install_id_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(dirs::config_dir)
.unwrap_or_else(std::env::temp_dir);
base.join("origin-client").join("install-id")
}
/// Returns the unique install ID to report to Origin, in priority order:
///
/// 1. `explicit`, if non-empty - a caller-supplied override.
/// 2. the real OS machine ID (`/etc/machine-id` on Linux, `IOPlatformUUID`
/// on macOS, `MachineGuid` on Windows), if readable.
/// 3. the ID already persisted at `path`, if present.
/// 4. a newly generated UUID, persisted to `path` for future runs.
///
/// OS-ID and persistence failures are logged as warnings, not returned as
/// errors: this always returns a usable ID.
pub fn resolve_machine_id(
explicit: Option<&str>,
path: Option<&Path>,
logger: &dyn Logger,
) -> String {
resolve_machine_id_with(detect_os_machine_id, explicit, path, logger)
}
pub(crate) fn resolve_machine_id_with(
detect: impl Fn() -> Option<String>,
explicit: Option<&str>,
path: Option<&Path>,
logger: &dyn Logger,
) -> String {
if let Some(id) = explicit.map(str::trim).filter(|s| !s.is_empty()) {
return id.to_string();
}
match detect() {
Some(id) if !id.trim().is_empty() => return id.trim().to_string(),
_ => logger.warn(
"machine id: OS machine id unavailable, falling back to a generated id",
None,
),
}
let resolved_path: PathBuf = path
.map(Path::to_path_buf)
.unwrap_or_else(default_install_id_path);
if let Ok(data) = fs::read_to_string(&resolved_path) {
let existing = data.trim();
if !existing.is_empty() {
return existing.to_string();
}
}
let id = Uuid::new_v4().to_string();
if let Err(err) = persist_install_id(&resolved_path, &id) {
logger.warn(
"machine id: failed to persist generated id, using an ephemeral id for this run",
Some(&err),
);
}
id
}
fn persist_install_id(path: &Path, id: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() {
fs::create_dir_all(dir)?;
}
}
fs::write(path, format!("{id}\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logger::FnLogger;
use std::sync::atomic::{AtomicBool, Ordering};
fn temp_install_id_path() -> PathBuf {
let dir = std::env::temp_dir().join(format!("origin-client-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir.join("install-id")
}
#[test]
fn explicit_override_wins() {
let got = resolve_machine_id_with(
|| Some("os-machine-id".to_string()),
Some("explicit-id"),
Some(&temp_install_id_path()),
&crate::logger::NoopLogger,
);
assert_eq!(got, "explicit-id");
}
#[test]
fn uses_os_machine_id_when_available() {
let got = resolve_machine_id_with(
|| Some("os-machine-id".to_string()),
None,
Some(&temp_install_id_path()),
&crate::logger::NoopLogger,
);
assert_eq!(got, "os-machine-id");
}
#[test]
fn falls_back_and_persists_when_os_id_unavailable() {
let path = temp_install_id_path();
let first = resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger);
assert!(!first.is_empty());
let second =
resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger);
assert_eq!(
first, second,
"second call should read the persisted id rather than generating a new one"
);
}
#[test]
fn logs_warning_when_os_id_unavailable() {
let warned = AtomicBool::new(false);
let logger = FnLogger(
|_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| {
warned.store(true, Ordering::SeqCst);
},
);
resolve_machine_id_with(|| None, None, Some(&temp_install_id_path()), &logger);
assert!(warned.load(Ordering::SeqCst));
}
}
+50
View File
@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
/// Payload is the JSON body posted to Origin's public service-checkin
/// endpoint. Field names and requiredness mirror
/// `POST /api/public/service-checkin` as documented in
/// `origin/doc/service-checkin.md`: `type`, `version`, `name`, and
/// `unique_install_id` are required; everything else is optional and, when
/// omitted, does not overwrite existing instance data on Origin's side.
#[derive(Debug, Clone, Default, Serialize)]
pub struct Payload {
#[serde(rename = "type")]
pub type_: String,
pub version: String,
pub name: String,
pub unique_install_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub site: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub internal_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub orchestrator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// CheckinResponse is returned by Origin after a successful check-in.
#[derive(Debug, Clone, Deserialize)]
pub struct CheckinResponse {
pub success: bool,
pub service_instance_id: i64,
pub unique_install_id: String,
pub status: String,
pub pinged_at: String,
}
+124
View File
@@ -0,0 +1,124 @@
use std::sync::Arc;
use std::time::Duration;
use origin_client::{Client, Config, FnLogger};
use tokio_util::sync::CancellationToken;
fn test_config(url: String) -> Config {
Config {
url: Some(url),
service_key: "test-key".to_string(),
type_: "icy2".to_string(),
version: "v1.0.0".to_string(),
name: "icy2-test".to_string(),
site: Some("Test Site".to_string()),
environment: Some("test".to_string()),
install_id: Some("test-install-id".to_string()),
..Default::default()
}
}
#[tokio::test]
async fn new_requires_fields() {
assert!(Client::new(Config::default()).is_err());
}
#[tokio::test]
async fn checkin_once_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/")
.match_header("x-origin-service-key", "test-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"success":true,"service_instance_id":42,"unique_install_id":"test-install-id","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#,
)
.create_async()
.await;
let client = Client::new(test_config(server.url())).unwrap();
let resp = client.checkin_once().await.unwrap();
mock.assert_async().await;
assert!(resp.success);
assert_eq!(resp.service_instance_id, 42);
assert_eq!(resp.unique_install_id, "test-install-id");
}
#[tokio::test]
async fn checkin_once_server_error_returns_error() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/")
.with_status(500)
.create_async()
.await;
let client = Client::new(test_config(server.url())).unwrap();
assert!(client.checkin_once().await.is_err());
}
#[tokio::test]
async fn checkin_once_unreachable_does_not_panic() {
let client = Client::new(test_config("http://127.0.0.1:1".to_string())).unwrap();
let result = client.checkin_once().await;
assert!(result.is_err());
}
#[tokio::test]
async fn checkin_once_logs_on_failure() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/")
.with_status(500)
.create_async()
.await;
let warned = Arc::new(std::sync::atomic::AtomicBool::new(false));
let warned_clone = warned.clone();
let mut cfg = test_config(server.url());
cfg.logger = Arc::new(FnLogger(
move |_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| {
warned_clone.store(true, std::sync::atomic::Ordering::SeqCst);
},
));
let client = Client::new(cfg).unwrap();
let _ = client.checkin_once().await;
assert!(warned.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn run_checks_in_immediately_then_stops_on_cancel() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"success":true,"service_instance_id":1,"unique_install_id":"x","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#,
)
.expect(1)
.create_async()
.await;
let client = Client::new(test_config(server.url())).unwrap();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
client.run(cancel_clone).await;
});
// The immediate check-in happens synchronously at the start of run();
// give the spawned task a moment to execute it, then cancel before the
// (24h) interval could ever fire again.
tokio::time::sleep(Duration::from_millis(50)).await;
cancel.cancel();
handle.await.unwrap();
mock.assert_async().await;
}