🤖 Client Agent Architecture¶
This documentation details the architecture of the Node.js client agent (client/), which runs next to keepalived on every host the Keepalived Status Monitor watches.
🔁 Connection Modes¶
The two modes are named from the server's point of view, and that is the only reading
used anywhere — in CONNECTION_MODE (shared/src/constants.ts), in the clients.connection_mode
column, in the REST API and in the dashboard:
| Mode | Who dials | What the agent needs |
|---|---|---|
inbound |
The agent dials the server (/ws/agent on the server) |
serverUrl in config.yaml and an identity in identity.json; the agent owns the reconnect ladder. The server checks the source address against clients.inbound_allowed_ip. |
outbound |
The server dials the agent (/ws/register and /ws/agent on the agent's own web server) |
A reachable listenPort (default 3011) and, for the first contact, the setup PIN from the agent's log or KASM_REGISTRATION_SECRET; the server owns the reconnect ladder and stores the agent's address as outboundTargetAddress. |
Read from the agent's side the words invert — an outbound client is the one that receives a
connection — so agent-side code and logs name the mode, not the local direction.
💻 Platform Support¶
ghcr.io/stefgo/kasm-client is one multi-arch image for x86_64 (linux/amd64) and ARM64 (linux/arm64), e.g. a Raspberry Pi; docker pull picks the matching variant. See Images and tags for the tags.
📂 Project Structure¶
The client is a lightweight, headless Node.js process designed to run as a daemon (either via Docker or systemd). It maintains a persistent WebSocket connection to the central server and exposes a local web UI for setup and status monitoring.
client/src/
├── core/
│ ├── Config.ts # What the operator wrote: read once, validated, frozen
│ ├── ConfigFile.ts # config.yaml itself: YAML document, comment-preserving writes
│ ├── Identity.ts # The clientId and authToken the server issued (data directory)
│ ├── RegistrationState.ts # What registration changes: serverUrl, registration secret, mode
│ ├── Connection.ts # Persistent WebSocket connection & message routing
│ ├── DataStore.ts # The agent's data directory: atomic, synced JSON files
│ ├── ServerHttp.ts # HTTP(S) requests to the server, certificate check decided per call
│ ├── secrets.ts # Constant-time comparison of the auth token and the registration secret
│ ├── SetupPin.ts # The PIN that guards registration, inbound and outbound
│ └── Version.ts # Agent version detection (VERSION file, git tags, git hash)
├── services/
│ ├── ActivityService.ts # Activity events: queue, at-least-once delivery
│ ├── KeepalivedService.ts # Finds keepalived, signals it, reads the dumps, diffs readings
│ ├── NotifyFifoWatcher.ts # Turns lines on keepalived's notify FIFO into readings
│ ├── procView.ts # The check at start: is this the host's process table?
│ └── parsers/
│ ├── common.ts # The fields taken over, state names, shared helpers
│ ├── dataDump.ts # /tmp/keepalived.data (SIGUSR1), text
│ ├── statsDump.ts # /tmp/keepalived.stats (SIGUSR2), counters
│ └── jsonDump.ts # /tmp/keepalived.json, for a keepalived built with --enable-json
├── web/
│ ├── server.ts # Local Fastify HTTP server (listenPort, default 3011)
│ └── public/
│ ├── register.html # Client registration UI
│ ├── status.html # Connection and keepalived status
│ ├── styles.css # Dark-theme stylesheet
│ └── favicon.svg
└── index.ts # Application entry point
🏗️ Core Components¶
1. Configuration (src/core/Config.ts, ConfigFile.ts, Identity.ts, RegistrationState.ts)¶
Four modules, along one line: configuration is what the operator wrote, state is what the agent was given.
| Module | Owns | Persisted in |
|---|---|---|
Config.ts |
The settings from config.yaml, validated against AgentConfigSchema and frozen — nothing writes to them at runtime. |
config.yaml (read only) |
ConfigFile.ts |
The YAML document itself, so an edit keeps the operator's comments, order and spelling. | config.yaml |
Identity.ts |
The clientId and authToken the server issued. Never in config.yaml: the operator does not write them, and the write has to be atomic. |
identity.json in the data directory |
RegistrationState.ts |
What a registration changes — the server URL, the registration secret once it is used — plus the agent mode derived from them. | server URL written back to config.yaml; the secret comes from the environment and is only dropped from memory |
A config.yaml from an older version that still holds clientId and authToken is migrated
at the next start: the pair is written to identity.json, and only once that worked are the
two keys removed from config.yaml together with their comments.
Agent mode (getAgentMode()) is derived in one place: no identity is unregistered, an
identity with a serverUrl is inbound (the agent dials), an identity without one is
outbound (the server dials). It is logged once at startup.
Config keys:
| Key | Description |
|---|---|
logLevel |
Log verbosity (trace…fatal, silent). Default: info, or LOG_LEVEL where the file says nothing. A value that is not a level is ignored with a warning. |
serverUrl |
HTTP(S) URL of the management server (e.g., https://manager:3010). Set it by hand, or let a registration through the web UI write it. Absent in outbound mode. |
keepalived |
How keepalived is read: pollInterval (s, default 5, 0 switches the timer off), notifyFifo and notifyToken (see Triggers; KASM_NOTIFY_FIFO and KASM_NOTIFY_TOKEN win over them), dataFile, statsFile, jsonFile (as keepalived sees them), jsonSignal (unset: text dump), dumpTimeoutMs (default 3000). Validated on start; an invalid value — or all three triggers off — stops the agent with a log line naming it. |
allowSelfSignedCertificates |
Accept a server certificate that does not validate, for registration and the WebSocket connection. Default false. A value that is not true or false is ignored with a warning. |
allowedNetworks |
IPv4 addresses or CIDR networks the server may dial /ws/register and /ws/agent from. Empty (default) allows every address. |
listenPort |
Port of the local web server (default 3011). KASM_CLIENT_PORT wins over it. |
enableStatusPage |
Serve /status (default true). |
enableRegisterPage |
Serve /register and accept POST /api/register while the agent is unregistered (default true). Both close once it has an identity. |
tls |
cert and key of a certificate the local web server presents, so the server can dial it as wss://. Unset (default): plain HTTP. See Serving it over TLS. |
2. WebSocket Connection (src/core/Connection.ts)¶
Manages the persistent WebSocket connection to the server at the ws/agent endpoint, presenting clientId and token in the query string. It does not dial at all until both halves are stored.
- Authentication: Sends the
authTokenas a query parameter on connect. Immediately sends anAUTHmessage with{hostname, version, capabilities}.capabilitiesis what this build can do (currentlyvrrp); the server reads it instead of comparing version strings, and an agent that predates a capability simply does not name it. - Heartbeat: Server sends a PING every 30 seconds; the client responds with PONG. If no ping is received within 35 seconds, the connection is considered dead and a reconnect is triggered.
- Reconnection: After a disconnect or a failed attempt the agent waits 5, 10, 30 and then 60 seconds between attempts, plus up to 3 seconds of random jitter each time, so a fleet does not return in lockstep after a server restart. The ladder is the same as the server's for outbound agents (
ClientConnector). It restarts at 5 seconds only after a successfulAUTH_SUCCESS, not merely when a socket opens. An attempt whose handshake does not finish within 5 seconds is terminated and counts as failed. All attempts run through one timer: a manual retry from the status page replaces a queued one, and a socket that has been superseded by a newer connection never schedules a reconnect of its own. - Message Routing:
SERVER_MESSAGE_HANDLERSis atype → handlertable, and both message handlers — the connection the agent dials and the one the server dials — dispatch throughrouteServerMessage(). The two branches used to stand once per direction although the server sends the same messages either way. An unknown type is dropped: a server of a newer build may know messages this agent does not.AUTH_SUCCESSstays outside the table, inconnect(), which ties the attempt timeout, the reconnect ladder and the promise it has to settle to it.
Handled events:
| Event | Direction | Description |
|---|---|---|
AUTH |
Client → Server | Initial handshake with hostname, version and the agent's capabilities. |
AUTH_SUCCESS |
Server → Client | Confirms connection is authenticated and active. Triggers an immediate reading and KEEPALIVED_UPDATE. |
AUTH_FAILURE |
Server → Client | Not handled on its own: the server closes the socket right after it, and the agent goes back to its reconnect ladder like after any other close. |
KEEPALIVED_UPDATE |
Client → Server | One reading of keepalived: whether it runs, its version and PID, the VRRP instances and sync groups with their counters. |
REQUEST_STATE_UPDATE |
Server → Client | Read keepalived now and send the result, whether or not it changed. |
ACTIVITY |
Client → Server | Events the agent has observed and has not had acknowledged yet, as a batch. |
ACTIVITY_ACK |
Server → Client | The ids the server stored. The agent drops them from its queue. |
After connect the agent sends a fresh reading at once and offers everything still in its activity queue. From then on a reading goes out whenever it differs from the last one sent — the counters aside — and at least every 30 seconds, so the counters stay current without a broadcast to every dashboard every five seconds.
How the protocol may change¶
Server and agent are updated separately, so every build has to speak to one of another age. Three rules make that possible, and they apply to both directions:
- A receiver drops what it does not know. An unknown message type, an unknown field: noted in the debug log at most, never answered with an error and never a reason to close the connection. This is why most additions need nothing else.
- A new field is optional. Making an existing field mandatory is a break and needs the same two steps as removing one: the sender first, the receiver a release later — never both in the same release.
- Vocabularies are read tolerantly. An unknown value of an enum on the wire is normalised to a known one or passed through as it came; it never makes the message it sits in unusable.
ActivityEventSchemais the worked example:kindis a plain string,levelandsourcefall back toinfoandagent.
What makes the capability mechanism one-directional is an assumption about who is newer: the server updates its agents, so it is in practice never the older of the two. That is why there is only the "server asks what the agent can do" direction and no server capabilities in AUTH_SUCCESS. The day an agent offers something an older server would not merely drop but act on wrongly, that direction has to be added — until then it would be a mechanism without a case.
3. Local Web Server (src/web/server.ts)¶
A local Fastify HTTP server, used for initial setup and status monitoring. It listens on listenPort from config.yaml (default 3011), which KASM_CLIENT_PORT overrides. The port matters beyond the web UI: in outbound mode the server dials /ws/register and /ws/agent on it, so a moved port has to be reflected in the client's target address on the server side.
Serving it over TLS¶
Plain HTTP unless config.yaml carries a tls block:
Relative paths resolve against the agent's directory. Both files are read and checked at startup, and a tls block that cannot be read ends the start rather than falling back to HTTP — an agent configured for TLS that quietly served plaintext would hand its auth token out on /ws/register while looking perfectly healthy. The log line after listen() names the scheme actually in use, as does the setup PIN block.
With TLS on, the client's target address on the server has to say so: wss://host:port. The two are set separately and have to agree — an address written wss:// against an agent serving HTTP fails to connect, and a bare address against a TLS agent does too.
A reverse proxy terminating TLS in front of the agent works just as well; leave tls unset, point the proxy at the plain port and write the client's target address as wss://. Note that allowedNetworks then sees the proxy's address, not the server's, because this Fastify runs without trustProxy (see Security Notes).
Self-signed certificates are the normal case here. The server verifies the agent's certificate unless security.allow_self_signed_agent_certificates is set in its own config.yaml — see TLS to an Outbound Agent.
Which routes are served¶
Settled at startup from config.yaml (getWebRoutes()):
| Routes | Served when |
|---|---|
/status, /api/status/connection, /api/status/config, /api/status/keepalived, POST /api/connect |
enableStatusPage (default true) |
/register, POST /api/register |
enableRegisterPage (default true); closed once the agent is registered |
/, /api/status/server, /api/status/auth, the static files |
either page is enabled |
/ws/register, /ws/agent |
outbound mode: no serverUrl |
/api/keepalived/notify |
keepalived.notifyToken is set |
/api/health |
the agent runs in its container image (KASM_CONTAINER=true); answers loopback only |
A serverUrl means inbound mode, which needs no route here — the agent dials the server.
Without one, an unregistered agent waits on /ws/register for the server to register it
with the setup PIN (or KASM_REGISTRATION_SECRET), and a registered one needs /ws/agent.
/ws/register refuses every caller once the agent has an identity. Since the routes are
fixed at startup while the mode is not, /ws/agent also refuses a connection once the agent
has been registered inbound through its register page.
The register page closes the same way, per request rather than at startup: once the agent
has an identity, /register redirects to /status (or answers 404 without a status page),
register.html is no longer served and POST /api/register answers 404. The setup PIN is
dropped with the registration, so there is nothing left the page could do. The status page
then shows how to start over instead of a link to the register page.
The static handler leaves out the HTML file of a disabled or closed page, so /status.html is not a
way around enableStatusPage: false. With no page, the notify endpoint and no outbound mode, the server
binds 127.0.0.1 for the health route alone; outside the container it then does not start
at all, and says so in the log.
Pages:
| Route | Description |
|---|---|
GET / |
Redirects to /register while the register page is open, otherwise to /status. |
GET /register |
Registration UI — form to enter Server URL, Registration Token and setup PIN. Only while the agent is unregistered. |
GET /status |
Status dashboard — shows server reachability, auth token, connection state, a one-line keepalived summary and which triggers are active. |
API endpoints:
| Route | Method | Description |
|---|---|---|
/api/status/server |
GET | Checks if the configured server is reachable via GET {serverUrl}/api/v1/ping. Takes no address from the caller: it needs no login, and an ?url= it used to accept let anyone who could reach the port have the agent probe its network. |
/api/status/auth |
GET | Returns {hasAuthToken, statusPage} — whether the agent is registered, and whether the register page may link on to the status page. |
/api/status/connection |
GET | Returns {connected: boolean} (live WebSocket state). |
/api/status/keepalived |
GET | The last reading in summary: {collected, running, version, error, instances, master, collectedAt}, plus triggers: {poll, fifo, endpoint} — the poll interval, off/open/missing for the FIFO, and whether the notify endpoint exists. No instance details — the page has no login. |
/api/keepalived/notify |
POST | Triggers a reading; called by a keepalived notify script. Exists only with keepalived.notifyToken set and requires Authorization: Bearer <notifyToken> (401 otherwise). The body — at most 1 KB, plain text or JSON — is logged at debug level and not otherwise read. Answers 202 before the reading is taken. Not covered by allowedNetworks: the caller is keepalived on the same host, not the server. |
/api/health |
GET | Liveness for the image's HEALTHCHECK: {status: "ok"} while the agent process answers. Independent of the server connection. Served only in the container image and only to loopback; 404 for everyone else. |
/api/connect |
POST | Attempts to establish a WebSocket connection to the configured server. While a handshake is under way, further calls wait for that one instead of starting over, so calling it in a loop cannot keep a disconnected agent offline. |
/api/register |
POST | Performs registration: checks the setup PIN, then calls POST {serverUrl}/api/v1/register. Body {url, token, pin}; 400 names the invalid field (url must be http or https), 403 on a wrong PIN. Every error carries stage — input (form or PIN, nothing was sent), server (502, nothing answered at the URL) or register (a server answered and refused) — which the register page uses in place of a separate reachability check. Only available while enableRegisterPage is not false, and 404 once the agent is registered. |
WebSocket routes (server dials the agent): /ws/register and /ws/agent first check the peer address against allowedNetworks. An empty list allows every address. /ws/agent then checks both halves of the identity from the query string — the token against the stored authToken and the clientId against the stored one, each mismatch closing with 4001 Unauthorized. The id is checked as well as the token because a target address pointed at the wrong host would otherwise report that host under somebody else's name.
4. Reading keepalived (src/services/KeepalivedService.ts)¶
keepalived has no status API. It writes its state to a file when it receives a signal, and
that is what the agent uses — on start, on every connection to the server, on a
REQUEST_STATE_UPDATE, and on whichever triggers are switched on. One reading:
- Find keepalived. Every
/proc/<pid>/commthat readskeepalivedis a candidate; the one whose parent is not a keepalived is the parent process. The agent runs withpid: host, so this is the host's process table. No process: the reading isrunning: false, which is an answer, not an error. - Signal it.
SIGUSR1asks for the state dump,SIGUSR2for the counters — both at once, because keepalived handles them in turn anyway. Withkeepalived.jsonSignalset, that signal replacesSIGUSR1and the JSON dump is read instead of the text one. - Wait for the file. A dump counts as written once its modification time is newer than
before the signal and its size has stopped changing — a read in the middle of the write
would parse half a dump.
keepalived.dumpTimeoutMsbounds the wait. Without an answer the signal is sent once more — keepalived drops a signal that arrives while it reloads — and only a second timeout makes the reading an error. - Read it through
/proc/<pid>/root. That path is keepalived's own view of the file system, so a systemdPrivateTmp=truechanges nothing and the host's/tmpdoes not have to be mounted. It needsSYS_PTRACE(and, under AppArmor, an unconfined profile). - Parse it (
services/parsers/). The text dump is read line by line askey = valuepairs inside a block, so a key a newer keepalived adds is skipped, not an error. The counters become flat snake-case keys (advertisements_received,priority_zero_sent). - Diff and report — see below. The reading is stored as
keepalived-last.json.
A failed reading — no permission to signal, a dump that never came — is running: true with
error set. The error names the missing capability where it can tell. keepalived's version is
read once per process out of its binary (/proc/<pid>/exe), because keepalived -v would
have to run the host's binary inside the agent's container.
The check at start (src/services/procView.ts)¶
running: false is what a host without keepalived reports — and what an agent reports that
was started without pid: host and sees only its own container. The server cannot tell the
two apart, so the agent says which one it is, once, in its log before the first reading:
| Log line | Level | What it means |
|---|---|---|
Found keepalived, PID … |
info |
The process is visible. Whether it can be signalled and read is the first reading's to say. |
No keepalived process is running on this host. … |
warn |
The agent sees the host's process table, and keepalived is not in it. |
No keepalived process found, and /proc is not the host's process table … |
warn |
The agent sees only its own container. Start it with pid: host. |
Cannot read the process table in /proc: … |
error |
/proc itself cannot be listed. |
The host's process table is recognised by its kernel threads: PID 2 is kthreadd on every
Linux host, and a container with a PID namespace of its own never sees one.
Triggers¶
Three things can start a reading. Each is switched on by its own key in the keepalived
block; any combination works, as long as one is on.
| Trigger | Switched on by | Latency after a failover | Set up on the host |
|---|---|---|---|
| Timer | pollInterval > 0 (default 5) |
up to pollInterval |
nothing |
| Notify FIFO | notifyFifo (KASM_NOTIFY_FIFO) |
the time a dump takes | vrrp_notify_fifo in keepalived.conf |
| Notify endpoint | notifyToken (KASM_NOTIFY_TOKEN) |
the time a dump takes | a notify script and a token file — see Faster Failover Detection |
The latency matters beyond the host's own list: the server's cluster events
(vrrp.master_changed and the like, see API) are made once the readings
of all hosts agree, so they come as fast as the slowest of them.
The FIFO and the endpoint only trigger. What a FIFO line or a request body says is logged at debug level and otherwise ignored; the reading is where the state comes from. A line in a format a newer keepalived writes, or one that is lost, costs latency and nothing else — and the timer, left on, catches whatever a trigger missed.
Triggers are coalesced. A trigger that arrives while a reading is in flight does not join it — that reading may have signalled keepalived before the change — but asks for one more reading after it. However many arrive meanwhile (a sync group writes a line for itself and one per instance), that is one: keepalived is never signalled more than one reading ahead.
The FIFO (services/NotifyFifoWatcher.ts) is opened through /proc/<pid>/root, like the
dumps, and read-write: on Linux that open neither blocks while keepalived has no writer on it
nor sees an end of file when keepalived closes it. The agent does not create the FIFO. Every
five seconds it compares the FIFO's inode and keepalived's PID with the open one and reopens
on a change — a keepalived restart — followed by one reading for whatever was written while
nobody listened. A missing FIFO is logged once per streak and shown on the status page. A FIFO
has one reader: point notifyFifo only at a FIFO nothing else reads, such as a
vrrp_notify_fifo_script.
The endpoint is POST /api/keepalived/notify on the agent's web server, which is started
for it even with both pages off. client/scripts/kasm-notify.sh is the script keepalived
calls; it never fails, so an agent that is down does not show up in keepalived's log.
With pollInterval: 0 keepalived is read only on start, on connect, on the server's request
and on a trigger. Keep the timer on unless a trigger is set up and has been seen to work.
What leaves the host. Instances and sync groups are built field by field from a fixed list:
name, state, configured state, interface, VRID, priority, effective priority, advertisement
interval, virtual addresses, sync group, last transition, and the counters. Nothing is copied
wholesale, so auth_pass — which the dump prints — and the rest of the configuration cannot
reach the wire.
State changes. The agent compares every reading with the previous one, and with the one on disk after a restart, so a failover while the agent was down is still reported:
| Kind | Level | When |
|---|---|---|
vrrp.state_changed |
error to FAULT, warning from MASTER, otherwise info |
An instance's state differs. occurredAt is keepalived's own Last transition, not the time of the reading. data carries from, to and both priorities. |
vrrp.instance_added / vrrp.instance_removed |
info |
An instance appeared or disappeared between two readings of a running keepalived. |
keepalived.started / keepalived.stopped |
info / warning |
The process appeared or went away. A stop reports no removed instances. |
keepalived.unreadable |
error |
A running keepalived could not be read. Once per streak, not per reading. |
The first reading of a new agent reports nothing: without a previous one there is nothing to compare, and listing every instance as new would bury the activity list.
5. Activity Service (src/services/ActivityService.ts)¶
What the agent has seen, on its way to the server.
- Events, not sentences. An event carries a
kind, alevel, a subject (instance name, VRID, interface) and adataobject. The wording is written in the dashboard, so an agent of an older version keeps reporting usable facts. - At-least-once delivery. The agent gives each event its id and keeps it until the server acknowledges that id with
ACTIVITY_ACK— not until it has been sent. The queue is offered again on every reconnect, and a minute after a batch that went out but was not acknowledged — the server withholds the ack for what it failed to store. The id makes a second copy a no-op on the server. A failover while the server is unreachable is therefore on record once it is back. The queue holds at most 500 events; past that the oldest go first.
6. Version Detection (src/core/Version.ts)¶
Resolves the agent version with the following priority:
VERSIONfile next to the build output (written byscripts/generate-version.shduringnpm run buildand the Docker build). The script takesAPP_VERSIONfirst, then the version from the rootpackage.json(with+<hash>when the commit carries no release tag) — see development.md.- Without that file, during development: exact
git tagon the current commit. - Fallback:
{branch}-{short-hash}[-dirty].
🔄 Registration Flow¶
Registration is a one-time setup step performed via the local web UI:
- Open
http://localhost:3011in a browser → redirected to/register. - Enter the Server URL (e.g.,
https://manager.example.com), a Registration Token (generated in the server's token management UI) and the Setup PIN from the agent's log. - The UI checks server reachability (
GET /api/v1/ping). - The agent verifies the setup PIN before it contacts the server, then calls
POST /api/v1/registerwith{token, hostname}. - The server responds with the client's identity: a
clientIdand a permanentauthToken, both issued by the server. - The client saves the identity to
identity.jsonin its data directory, and the server URL toconfig.yaml— the latter only if it differs from what is already there. If either write fails, the register page says so: the agent is connected, but it would come back unregistered. - The client connects via WebSocket straight away.
Once registered, / leads to the status page instead:

The status page of a registered agent. It says whether keepalived was read and how changes are picked up — here polling plus the notify FIFO.
Outbound registration¶
The server registers the agent itself when it cannot be reached from the agent:
- The operator opens Add Client → the server connects to the agent in the dashboard and enters the agent's address and its Setup PIN from the log.
- The server dials
/ws/registerand sendsREGISTRATION_REQUESTwith the PIN, a freshauthTokenand theclientIdit issued. - The agent accepts
KASM_REGISTRATION_SECRETif one is set, otherwise the current setup PIN, stores the identity inidentity.jsonand answersREGISTRATION_SUCCESS. - The server opens
/ws/agentwith that identity.
Nothing has to be written into config.yaml for this. For an unattended rollout, where nobody
reads the agent's log, set KASM_REGISTRATION_SECRET — or KASM_REGISTRATION_SECRET_FILE
naming a file that holds it, such as a Docker secret — and enter that value in the wizard
instead. It is read from the environment only; setting both variables, or a file that cannot be
read or is empty, stops the agent. Once used it is dropped from memory and the log asks for it to
be removed from the environment. A registrationSecret left in config.yaml by an earlier
version is ignored with a warning.
Setup PIN (src/core/SetupPin.ts)¶
POST /api/register and /ws/register decide which server the agent obeys from then on —
and with it, who receives what it reports about the host. Both listen on every interface, so
they are guarded by a PIN that is printed to the agent's log once the web server listens:
──────────────────────────────────────────────
Setup PIN: K7QM-3XRD
Web UI: http://<this-host>:3011/register
Outbound: enter it in the server's Add Client wizard
The PIN is required to register this agent.
──────────────────────────────────────────────
- Read it with
docker logs kasm-client(or wherever the agent logs to). Case and the hyphen do not matter. - It is generated at every start of an unregistered agent and never written to
config.yaml. TheWeb UIline appears only with the register page, theOutboundline only while an agent withoutserverUrlis unregistered. - It is dropped with the first successful registration, and the register page closes with it. To register an agent again, delete
identity.jsonfrom its data directory and restart it — it then prints a new PIN — and delete the client's old entry on the server. - After 5 wrong attempts — on either route — the PIN is replaced by a new one (also logged), which ends online guessing without locking the operator out.
- With
enableRegisterPage: falseneither the page norPOST /api/registerexists. A PIN is then generated only while the agent also has noserverUrland noKASM_REGISTRATION_SECRET.
🔁 Process Lifecycle (src/index.ts)¶
- Startup: starts the local web server with the routes
config.yamlcalls for (none at all: no server) and waits for it, then starts reading keepalived, then opens the connection to the server. The readings start before the connection on purpose: they belong to the host, not to the link, so an agent that comes up while the server is unreachable still notices a failover and reports it once there is somewhere to report to. A failedlisten()is logged and the agent continues without its web UI, as before; an unusablelistenPortends the start before that, because a silent fallback would put the agent on a port nobody expects. - Unhandled promise rejections are logged at
errorlevel and the agent keeps running, so it stays connected to the server that monitors this host. - Uncaught exceptions are logged at
fatallevel and the process exits with code 1 after 250 ms (time for the pino transport to flush), to be restarted by the supervisor (restart: unless-stoppedincompose.yaml). - Both handlers are registered only after startup.
SIGINT/SIGTERMstop the web server and exit with code 0.
🗄️ Data Storage¶
Everything the agent has to survive a restart lives in its data directory
(src/core/DataStore.ts). config.yaml holds what the operator wrote, and nothing else:
| File | Owner | Contents |
|---|---|---|
identity.json |
the server | The clientId and authToken issued at registration. Lose it and the agent has to be registered again. |
keepalived-last.json |
the agent | The last reading, so a restart can still tell what changed while the agent was away. |
queue.json |
the agent | Activity events the server has not acknowledged yet. |
- Where it is.
/app/client/datain the container, or<client>/databeside the source outside one;KASM_CLIENT_DATA_DIRoverrides both. It is deliberately not next toconfig.yaml: that file is a single-file bind mount, so anything written beside it lives in the container's own filesystem and is gone with the next recreate.compose.yamlmounts a named volume here, and it has to stay one.KASM_CLIENT_CONFIGmovesconfig.yamlitself. - Writes are atomic and synced: a temporary file,
fsync, then a rename, then anfsyncon the directory. A host that loses power mid-write is exactly the situation this state exists for, and a half-written file is what rename cannot leave behind; the syncs keep the rename from carrying an empty file on a filesystem that reorders the two. - The directory belongs to the agent alone. It is created with mode
0700and set to it again at every start, and every file is written0600:identity.jsonholds the auth token in plain text. - A damaged scratch file is discarded, not fatal. An agent that will not start because of its own scratch file is the worse failure — the connection an operator would fix it over is the one it is refusing to open.
- A damaged identity is set aside, not discarded. An
identity.jsonthat does not parse, or is not the pair, is renamed toidentity.json.corrupt-<timestamp>and reported in the log as an error. It is the only copy of the registration, and the next registration would otherwise write over it. The agent starts unregistered either way. - The activity queue holds at most 500 events and nothing older than seven days; the oldest
go first. The age is checked before every send, not only when the queue is read back at
startup, so an agent that stays up through a long outage does not deliver stale events
either. Writes are coalesced over a second, and a
SIGTERM— or an uncaught exception — flushes what is pending before the process ends.
There is no local database.
🔐 Security Notes¶
- The
authTokenis stored in plain text inidentity.jsonin the agent's data directory. The agent keeps that directory at mode0700and the file at0600. The token is masked in the agent's log and never written toconfig.yaml. - The agent needs
pid: host,KILLandSYS_PTRACE— see Agent Permissions. It gets no Docker socket and no host file system mount, and it sends keepalived nothing butSIGUSR1,SIGUSR2and, if configured, the JSON signal. It is nonetheless root on the host in all but name, sinceSYS_PTRACEreaches every host process — see What These Permissions Amount To. - Registration — through the local web UI or by the server on
/ws/register— requires the setup PIN from the agent's log (see Setup PIN), or on/ws/registeralternativelyKASM_REGISTRATION_SECRET. SetenableRegisterPage: falseonce no re-registration is expected. - The server's TLS certificate is verified for registration and for the WebSocket connection. For a server with a self-signed certificate set
allowSelfSignedCertificates: true; it then applies to both. The reachability check on the status and register pages always tolerates such a certificate — it sends nothing and only answers whether a KASM server responds. The decision is passed per request (core/ServerHttp.ts, the WebSocket options) and never through the process-wideNODE_TLS_REJECT_UNAUTHORIZED, which the agent used to set on its first request and never reset. - Agent connections are validated server-side against
security.allowed_networksand the client's own allowed address or network, which can be edited or switched off in the client editor. allowedNetworksin the agent'sconfig.yamlrestricts where the server may dial/ws/registerand/ws/agentfrom (empty: no restriction). Refused connections are closed with4003 Access deniedand logged with the peer address; the local web UI is not restricted.- In outbound mode the agent's own certificate is what protects the session: the auth token travels in the
/ws/agentquery string, so without TLS it is readable by anything on the path. Set atlsblock (see Serving it over TLS) for any agent the server does not reach over a network you control. - The
authTokenandKASM_REGISTRATION_SECRETare compared in constant time (core/secrets.ts), so a wrong value reveals nothing through how long the check took. TheclientIdbeside the token is compared normally — it is not a secret and appears in every dashboard URL.
📦 Key Dependencies¶
| Package | Version | Purpose |
|---|---|---|
fastify |
^5.x | Local web server |
@fastify/static |
^10.x | Static file serving |
@fastify/websocket |
^11.x | /ws/register and /ws/agent for outbound mode |
ws |
^8.x | WebSocket client |
yaml |
^2.x | Config file parsing |
@kasm/shared/node |
workspace | Pino logger, the same module the server uses (pino ^10, pino-pretty ^13 are dependencies of shared) |