Skip to content

Agent enrollment: from a new machine to an approved client ​

Enrollment answers “should this Servomon instance trust this new agent?” It happens before metrics ingestion or the WebSocket ticket flow. The operator approves a request in the dashboard; the server creates a per-agent bearer token; the agent saves it locally for future requests.

This document describes the implementation currently in the repository, not an idealized protocol.

Security boundary: operator approval is required and protected against forged cross-site requests. Token collection requires a device-only secret that is never displayed; the pairing code alone only identifies the request. The generated keypair does not authenticate the agent: no signature or private-key proof is checked. Treat the pairing code as a temporary secret anyway. The security limitations below list what remains before calling this cryptographically verified device enrollment.

Three different questions ​

  • Enrollment: may this machine join? Answered by the operator's approval.
  • Agent authentication: which enrolled system is making this HTTP request? Answered by its AGENT_TOKEN and the server's token record.
  • WebSocket admission: may this connection open now, and as which role? Answered by a separate short-lived, single-use ticket.

The pairing code is not the agent token, and the agent token is not the WebSocket ticket.

1. What starts enrollment? ​

agent/main.ts loads the environment using @std/dotenv/load. It calls enrollAgent(SERVER_URL, NAME, Deno.cwd()) only when AGENT_TOKEN is empty or absent.

Important consequences:

  • Building or replacing the executable does not itself trigger enrollment.
  • A stale or revoked nonempty token also skips enrollment. The server rejects it; the agent does not automatically clear it or re-enroll.
  • The working directory matters. The .env and .servomon-identity.json involved here are in Deno.cwd(), not necessarily beside the executable. Set the service's WorkingDirectory accordingly.
  • NAME must pass the server's validation: 1–64 characters, starting with an ASCII letter or number, then letters, numbers, ., _, or -.
  • SERVER_URL must point at the intended instance. Use HTTPS outside an isolated localhost check. The current helper does not enforce an HTTPS URL itself.

2. What gets created, and which values are secrets? ​

ValueMeaning and creationStorage / lifetimeSecurity role
system / NAMEHuman-readable monitored-system nameAgent configuration and server recordAn identifier, not a credential
agentIdagent- plus crypto.randomUUID() on the agentLocal identity file and server recordAn identifier, not proof of identity
P-256 keypairGenerated with Web Crypto ECDSABoth keys, exported as JWK strings, in .servomon-identity.jsonPrivate material exists, but is not used for signing or authentication in this implementation
fingerprintSHA-256 of the UTF-8 public-key JSON stringPending request and enrolled recordA comparison label; the server never receives the public key or verifies that this hash belongs to it
Pairing codeServer-generated 6 random bytes encoded as 12 uppercase hex characters: 48 bitsServer memory, expires 15 minutes after the initial requestIdentifies the request for approval and status polls; alone, it no longer collects the token
Collector secretAgent-generated 32 random bytes, hex encoded: 256 bitsAgent identity file; SHA-256 hash on the pending requestProves the collector is the device that made the request; never displayed or logged
AGENT_TOKENServer-generated 32 random bytes, base64url encoded: 43 characters, 256 bitsPlaintext in agent .env; salted scrypt hash on server; no automatic expiryThe actual reusable bearer credential
AGENT_IDAgent id written into .env after collectionAgent .envInformational; runtime authentication uses the token, not this setting
WebSocket ticketMinted later using the tokenMemory; normally 60 seconds and single-useAdmission to one WebSocket connection, explained separately in WebSocket authentication

A fingerprint hash and a token hash serve different purposes. The fingerprint is displayed to help a human compare requests. The salted token hash lets the server verify a supplied secret without keeping a recoverable copy of that secret in the agent registry. Neither operation encrypts the token for delivery.

The fingerprint is specifically a hash of a JSON string, not a standardized, canonical JWK thumbprint. Both the agent console and dashboard show only its first 19 hex characters followed by an ellipsis; the server stores the full submitted value.

3. First run, step by step ​

Local identity ​

agent/lib/enroll.ts first tries to read .servomon-identity.json. If it contains agentId, privateKey, and publicKey, those values are reused. If the file is missing, unreadable, or lacks those fields, a new identity is generated and written with requested mode 0600.

This is a field-presence check, not a cryptographic validation of the file. The file is not uploaded. Only the agent id, system name, and fingerprint are sent to the server.

Request, approval, and delivery ​

Request. POST /api/enroll/request is intentionally public: a machine has no credentials yet. The server validates the submitted fields, rejects an already-enrolled or already-pending system name, rejects an already-enrolled agent id, then stores a pending record in memory. It returns {code, expiresAt}; no agent token exists at this stage.

If the same agent id already has a live pending request, matching system and fingerprint return its existing code. Different metadata for that pending id returns 409. The collector hash is bound on first submission and never rotated by re-requests, so replaying public metadata returns the code but never the collection secret.

Operator review. The dashboard fetches pending requests and enrolled agents when it loads, then every 15 seconds on every page (useFleet.ts), so the Agents link in the sidebar shows a badge while requests are waiting. Only an authenticated operator can list, approve, or deny pending requests. Approval and other unsafe operator calls require a same-origin request (browser Origin must match the host) or, without Origin, a JSON body — so a forged cross-site form post is rejected with 403. Compare the code shown by the terminal you started, not just a familiar hostname. A displayed fingerprint is not independently verified by the server.

Approval. The server looks up the pending code, checks its status and expiry, and under an approval lock checks that neither its system name nor its agent id is enrolled before persisting. It generates a fresh token, hashes it using scrypt with a random 16-byte salt and 32-byte output, and persists a record:

text
agentId, system, tokenHash, fingerprint, createdAt, lastSeenAt

The tokenHash is stored as saltHex:derivedHex. lastSeenAt starts as null. Although createAgentRecord() generates an id, the approval handler overwrites it with the id submitted by the agent; that client-supplied id is the registry key actually used. Concurrent approvals of the same code serialize: exactly one wins, the other gets 410.

The plaintext token is kept on the approved in-memory enrollment record until collected. The approval response to the dashboard contains only the agent id and system name, not the token.

Collection. The agent waits five seconds between polls to GET /api/enroll/:code, sending its collector secret as Authorization: Bearer <secret>. Status polls need only the code and return 202 while pending; once approved, the token is returned only with the correct secret (401 otherwise). The response carries Cache-Control: no-store. The handler clears the token from the in-memory record before returning it. A later poll returns 410.

This is one successful collection-handler execution, not guaranteed delivery to exactly one intended device. The handler has no acknowledgement from the agent that the response arrived or was saved.

Saving and continuing. The helper writes AGENT_TOKEN= and AGENT_ID= to the working-directory .env atomically (temp file + rename) with mode 0600. main.ts also sets the token in the current process, so it can continue without a restart. The agent now posts metrics and exchanges its token for WebSocket tickets. It does not send the pairing code or use its private key for those requests.

4. Pending, approved, collected, denied, expired ​

Collected, Expired, Lost, and Enrolled above describe observable states, not extra database enum values. The in-memory enum has only pending, approved, and denied; an approved record whose token is absent has been collected. The persistent registry and pending request are separate stores.

  • The deadline is 15 minutes from request creation, not 15 minutes from approval. Approving near the deadline leaves little time to collect.
  • Expiry blocks collection but does not automatically remove a registry record already saved during approval.
  • Server restart loses pending requests and uncollected plaintext tokens. Previously enrolled registry records survive in the mounted filesystem.
  • A lost collection response or failed .env write leaves an enrolled server record but no usable local credential. The server cannot reconstruct the token from its hash.

5. Endpoint and failure reference ​

EndpointWho calls itExpected result
POST /api/enroll/requestUnauthenticated agent200 with code and expiry; 400 invalid fields; 409 enrolled/pending name, enrolled id, or conflicting pending agent id; 429 rate limit
GET /api/enroll/pendingOperator, Basic auth200 with live pending records
POST /api/enroll/approveOperator, Basic auth, same-origin/JSON gate200 with id and system; 403 forged cross-site or foreign-origin request; 410 unavailable request; 409 enrolled name or id
POST /api/enroll/denyOperator, Basic auth, same-origin/JSON gate200 denied; 403 forged request; 410 unavailable request
GET /api/enroll/:codeHolder of the code202 waiting; 200 with token once, only with the collector secret (401 otherwise) and Cache-Control: no-store; 410 missing, denied, expired, or collected; 429 rate limit
GET /api/agentsOperator, Basic authPublic agent metadata, never the stored token hash
DELETE /api/agents/:idOperator, Basic auth, same-origin gateRemoves the credential record and closes its agent sockets; retains metrics history
DELETE /api/systems/:systemOperator, Basic auth, same-origin gateRemoves matching enrollment and metrics, alert configuration, and liveness state

The server allows 30 enrollment requests per rolling minute per IP and 60 polls per rolling minute per IP, using separate in-memory buckets. A single agent polling every five seconds makes about 12 polls per minute, so simultaneous enrollments behind one NAT share the poll allowance. IP selection uses forwarded headers; only trust those headers behind a proxy that controls them.

The agent continues only on 202 and successful collection. It exits on 410, 429, other HTTP errors, or a network exception; enrollment polling has no retry/backoff for these failures. The local 15-minute wait budget starts after the request response, while the server enforces its own original request expiry.

6. File storage and recovery ​

Protect both local files, and exclude them from source control and public support logs. The helper creates the identity file with mode 0600, writes both files atomically (temp file + rename), and enforces 0600 on .env after saving the token, so a pre-existing 0644 .env is tightened automatically.

SituationCurrent recovery
Agent has a stale nonempty token and receives 401Stop it, remove its enrollment in the dashboard if still present, clear AGENT_TOKEN in its working-directory .env, and restart to request approval again
Approval succeeded but token was lost before savingRemove the orphaned enrollment, leave the local token empty, restart, and approve a new request
Agent process restarted while still pendingThe identity file is reused; a still-live matching pending request returns its original code without extending its deadline
Server restarted while pendingRestart the agent to request a new code; the old code is unavailable
Operator clicked RemoveServer revokes enrollment but does not alter local files; the agent must be re-enrolled manually to regain access
System remains visible after RemoveIts metrics history is retained intentionally. Use Delete system… in the system's ⋯ menu on the overview if that history should also be purged

Copying an enrolled agent's .env to another machine copies its authority. The keypair does not prevent cloning: either machine can authenticate using the same bearer token.

7. Why this enrollment style? ​

This resembles approving a headless CLI from a browser: the agent needs only outbound HTTP, while the human uses the dashboard on another device. It does not require an inbound port for enrollment, SSH into the web host, an identity provider, or redeploying the server for every agent.

ChoiceBenefitCost or consideration
Dashboard approval rather than one shared fleet tokenPer-agent identity and individual revocation, without editing server environment variablesOperator must verify which pending request is theirs; public submissions are untrusted
Agent polls rather than receiving a callbackWorks behind NAT and firewalls with outbound HTTP onlyPolling delay and load; shared-IP limits can affect multiple agents
Pending state in memoryNo database needed for a single-instance deploymentRestart loses pending state and uncollected credentials; multiple machines would need shared state
Persistent, hashed per-agent bearer tokenSimple HTTP authentication and registry storage without recoverable token copies on diskToken is plaintext on the agent, has no automatic expiry, and can be copied; registry token checks scan agents and use synchronous scrypt
One-use, secret-bound collectionLater replay cannot obtain the plaintext again; only the requesting device can collectDelivery failures require manual recovery
Keypair and visual fingerprintStable local comparison labelCurrently adds cryptographic-looking machinery without proof of key ownership; not an authentication factor

Similar to OAuth device flow, but not OAuth ​

RFC 8628 and GitHub's device flow use separate values:

  • user_code: a human-facing value used to identify the request being approved.
  • device_code: a separate device-held value used to poll for and receive the credential; it is not displayed to the user.

Servomon now follows the same split: the pairing code is the human-facing approval identifier, and the collector secret is the device-held collection credential. Its route names, JSON responses, errors, and credential lifetimes remain custom. There is no OAuth authorization server, scope negotiation, refresh token, or standardized device grant implementation. The useful comparison is the interaction shape, not protocol conformance or equivalent security guarantees.

These are implementation limits, not properties fixed by HTTPS or by the WebSocket ticket layer.

  1. The fingerprint is a submitted string, not verified identity. The server accepts any fingerprint string of 8–128 characters; it does not receive a public key, recompute a fingerprint, issue a challenge, or verify a signature. The generated private key is never used. Copying the label is enough to copy what the UI displays. An operator matching it is not verifying private-key ownership. Either remove the unused keypair and describe the scheme as bearer-token enrollment, or implement real challenge/signature verification before claiming proof of possession.
  2. One-use delivery is not acknowledged or recoverable. The plaintext is cleared before the response is delivered and before the agent saves it. Failure, expiry after approval, or a server restart can strand a registry record without a usable token. A recoverable delivery acknowledgement would close this gap; manual removal and re-enrollment is the current recovery.
  3. No automatic credential expiry or rotation. Agent tokens live until removed. Add expiry/rotation if the deployment needs bounded credential lifetimes.

Fixed by this hardening pass: forged cross-site approval (same-origin/JSON gate), unbound token collection (collector secret), duplicate pending names and registry overwrites (pending reservation + approval lock), missing cache policy (no-store on collection), and insecure local saves (atomic 0600 writes).

Code map and verification ​

FileResponsibility
agent/main.tsLoads configuration; enters enrollment only without a token; continues with received credentials
agent/lib/enroll.tsIdentity generation, fingerprint, collector secret, public request, five-second polls, atomic local .env update
web/server/middleware/10-api-auth.tsExposes request and code-poll endpoints; requires operator auth plus the same-origin/JSON CSRF gate for unsafe calls
web/server/api/enroll/Request, pending list, locked approval, denial, secret-bound collection handlers
web/server/utils/ticketStore.tsPending enrollment records, code generation, collector-hash checks, expiry checks, and IP buckets; also stores the separate WebSocket tickets
web/server/utils/agentStore.tsToken generation and salted hashes, registry reads and writes, revocation
web/pages/agents.vue, web/composables/useFleet.tsPending review UI, approval/denial, enrolled list and Remove action; shared polling of pending requests and agents
web/test/HTTP/WebSocket regression coverage using synthetic enrollment records

The suite covers forged-form and foreign-origin rejection, device-secret collection binding, pending-name and agent-id uniqueness, concurrent-approval serialization, token-authenticated ingestion, cross-system ingestion rejection, and credential/socket revocation. It does not establish private-key proof, safe recovery after a lost response, or filesystem permission enforcement.

An isolated local smoke run used the freshly built server and the real Deno enrollAgent helper, without a command executor. It observed identity creation, 202 while pending, 401 for anonymous approval, operator approval, token saving, 410 on repeated collection, a hashed registry record, successful ticket minting, denial, and rejection after removal. Fifteen-minute expiry and crash-recovery descriptions come from source tracing, not a timed expiry or crash test.

Further reading ​