⚙️ Backend Architecture¶
This documentation details the architecture of the server backend (server/backend), which serves as the control plane for the Keepalived Status Monitor.
📂 Project Structure¶
The backend is built using Fastify as the core framework, written in TypeScript. It follows a layered architecture: Routes → Controllers → Services → Repositories.
server/backend/src/
├── config/
│ └── AppConfig.ts # Configuration management (JWT, OIDC, settings, security)
├── controllers/ # HTTP and WebSocket request handlers
│ ├── ActivityController.ts # Activity list, seen state, deletion
│ ├── AuthController.ts
│ ├── ClientController.ts
│ ├── KeepalivedController.ts # Readings, clusters, asking an agent to read now
│ ├── SettingsController.ts
│ ├── TokenController.ts
│ ├── UserController.ts
│ ├── WebhookController.ts # Webhook CRUD and test delivery
│ ├── WebSocketController.ts
│ └── websocket/
│ ├── AgentMessageRouter.ts # Dispatch table for messages from authenticated agents
│ ├── AgentSession.ts # The AUTH handshake and connection lifecycle, both directions
│ └── Heartbeat.ts # Shared ping/pong heartbeat for all WebSocket kinds
├── core/ # Core infrastructure
│ ├── Database.ts # SQLite initialization & migration runner
│ ├── HealthFile.ts # Writes the address the container HEALTHCHECK asks
│ └── migrations/
│ ├── context.ts # MigrationContext: the database Umzug hands a migration
│ ├── 00_initial.ts # users, clients, registration_tokens, activity
│ ├── 01_keepalived_state.ts # keepalived_state: the last reading per client
│ ├── 02_client_site.ts # clients.site: part of the VRRP cluster key
│ ├── 03_scheduler_state.ts # scheduler_state: last run and state per scheduler
│ ├── 04_registration_token_hash.ts # registration_tokens: store the SHA-256 hash only
│ ├── 05_activity_seen.ts # activity.seen_by -> activity_seen table
│ ├── 06_scheduler_next_run.ts # scheduler_state.next_run_at: the planned run
│ ├── 07_keepalived_last_instances.ts # keepalived_state.last_instances: the last known instances
│ ├── 08_webhooks.ts # webhooks: targets events are reported to
│ └── 09_vrrp_cluster.ts # vrrp_cluster: what was confirmed per cluster, roles and incident
├── repositories/ # Database access layer
│ ├── ActivityRepository.ts # activity access (insert, dedup, retention)
│ ├── ClientRepository.ts
│ ├── HealthRepository.ts # SELECT 1 for the liveness probe
│ ├── KeepalivedStateRepository.ts # keepalived_state access
│ ├── SchedulerStateRepository.ts # scheduler_state access
│ ├── TokenRepository.ts
│ ├── UserRepository.ts
│ ├── VrrpClusterRepository.ts # vrrp_cluster access
│ └── WebhookRepository.ts # webhooks access, last delivery result
├── routes/
│ └── api.ts # Fastify route registration (all endpoints)
├── services/ # Business logic
│ ├── AuthService.ts # Authentication, OIDC flow, JWT
│ ├── SessionCookie.ts # The httpOnly session cookie and the readable flag beside it
│ ├── ClientConnector.ts # Dials an outbound client's agent
│ ├── KeepalivedStateService.ts # Store readings, broadcast them, build clusters
│ ├── ClusterSnapshot.ts # The clusters one evaluation looks at, the summary events carry
│ ├── ClusterEventService.ts # vrrp.master_changed / split_brain / master_lost, vrrp.incident_*
│ ├── ReadingRequests.ts # Asks agents for the readings a pending cluster state waits for
│ ├── ActivityService.ts # Activity ingest, dedup, ack + dashboard broadcast
│ ├── NotificationCleanupService.ts # Retention cleanup for the activity list
│ ├── ProxyService.ts # WebSocket connection management & broadcasting
│ ├── ScheduledJob.ts # Timer, run bookkeeping and status of a scheduler
│ ├── SettingsService.ts # Settings retrieval, update & persistence
│ ├── TokenCleanupService.ts # Retention cleanup for invalid registration tokens
│ └── WebhookService.ts # Renders and delivers events to the webhooks
├── types/
│ └── fastify.d.ts # Fastify request/instance augmentations
└── index.ts # Fastify server setup & entry point
🏗️ Core Components¶
1. Routes (src/routes/api.ts)¶
All routes are registered as a single Fastify plugin under the /api prefix. Protected routes apply request.jwtVerify() middleware. A valid signature is not enough on its own: the hook, and the dashboard handshake in WebSocketController, also ask AuthService.isSessionCurrent, which compares the token's tv with users.token_version (migration 10). UserRepository raises that counter on a password change and on changed auth methods; UserController then closes the user's dashboard sockets through ProxyService.closeDashboardSessions — with 4001, or 4002 plus a fresh cookie when the user changed their own password.
Public routes:
- POST /api/login — Local authentication; sets the session cookies
- POST /api/auth/logout — Clears the session cookies
- GET /api/auth/config — Auth type configuration
- GET /api/auth/login — OIDC redirect
- GET /api/auth/callback — OIDC callback; sets the session cookies and redirects to /
- POST /api/v1/register — Client self-registration
- GET /api/health — Liveness (process + database), used by the container HEALTHCHECK
- GET /api/v1/ping — Reachability ("is there a KASM server at this URL"), checks nothing on purpose
Protected routes (JWT required):
- Session: GET /api/v1/me — id, username and expiry of the current session
- Users: GET/POST /api/v1/users, PUT/DELETE /api/v1/users/:userId
- Clients: GET /api/v1/clients, POST /api/v1/clients/outbound, PUT/DELETE /api/v1/clients/:clientId, POST /api/v1/clients/:clientId/reconnect
- Tokens: GET/POST /api/v1/tokens, DELETE /api/v1/tokens/:tokenHash
- keepalived: GET /api/v1/keepalived/states, GET /api/v1/keepalived/clusters, GET /api/v1/clients/:clientId/keepalived, POST /api/v1/clients/:clientId/keepalived/refresh
- Settings: GET/PUT /api/v1/settings/cleanup, POST /api/v1/settings/cleanup/{invalid-tokens,notifications}, GET /api/v1/settings/scheduler-status
- Activity: GET/DELETE /api/v1/activity, POST /api/v1/activity/seen
- Webhooks: GET/POST /api/v1/webhooks, POST /api/v1/webhooks/test, PUT/DELETE /api/v1/webhooks/:webhookId
The full reference is in api.md.
WebSocket routes:
- GET /ws/dashboard — Dashboard real-time feed (JWT from the kasm_session cookie)
- GET /ws/agent — Client agent connection (clientId + authToken via query params)
2. Controllers (src/controllers/)¶
Controllers parse HTTP/WebSocket input, delegate to services, and format responses.
Input validation. Every handler that reads a body or a query string runs it through a Zod schema from @kasm/shared first and answers 400 with firstIssue(error) — the path of the first failing field plus the Zod message — before touching a repository, the config file or an agent:
const parsed = CreateUserSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: firstIssue(parsed.error) });
}
const { username, password, auth_methods } = parsed.data;
request.body as any does not appear in the controllers any more. Rules about a combination of fields (a local user needs a password, an allowed address only for an inbound client) stay in the controller; the schema describes the shape. request.user is typed through src/types/fastify.d.ts as { username: string; id: number; tv: number } — exactly what jwt.sign puts into the token.
| Controller | Responsibilities |
|---|---|
AuthController |
Local login, OIDC redirect & callback, PKCE flow management. |
UserController |
User CRUD — enforces self-deletion prevention and minimum user count. |
ClientController |
Client list (with live status and capabilities), adding an outbound client, editing display name, allowed address and target address, deletion (with the client's reading), reconnecting an outbound client. |
TokenController |
Registration token generation, listing, deletion, and client self-registration. |
KeepalivedController |
Every client's last reading, the clusters built from them, one client's reading, asking one agent to read now. |
ActivityController |
The activity list, per-user seen state, deletion of all of it. |
SettingsController |
Retrieve/update the settings block of config.yaml (never security), trigger manual cleanups. |
WebhookController |
Webhook CRUD and the test delivery of an unsaved configuration. |
WebSocketController |
Dashboard and agent WebSocket lifecycle (auth, heartbeat, message routing). |
3. Services (src/services/)¶
Services contain the business logic shared across controllers.
AuthService¶
initializeAdmin()— Creates a defaultadminuser (password:"admin") if the database is empty on first startup.checkLocalAuth(username, password)— Validates credentials against bcrypt-hashed passwords.getAuthConfig()— Returns local/OIDC configuration for the frontend.generateOidcUrl()— Builds the OIDC authorization URL with PKCE code challenge and state.handleOidcCallback(currentUrl)— Validates state, exchanges code for tokens, fetches userinfo.
ProxyService¶
The central hub for all real-time communication.
- Agent tracking:
registerClient/unregisterClient— manages the map of connected agent WebSockets. A new connection under an id that is already connected replaces the old one, which is closed with4000 Replaced by new connection. - Capabilities:
registerClientalso keeps what the agent declared in itsAUTH.hasCapability(clientId, capability)is what server-side decisions ask;getCapabilitiesreports the list onwards (nullwhile offline);getConnectedClientIdslists who is connected. Capabilities live with the connection, not in the database: they describe the build on the wire. - Dashboard tracking:
addDashboardClient/removeDashboardClient— manages all active dashboard sessions, each with the id of the user whose session cookie opened it. - Status enrichment:
getClientsWithStatus()— augments database records with live online/offline status. - Broadcasting:
broadcastClientUpdate()sendsCLIENTS_UPDATEto all dashboards;broadcastToDashboard()multicasts arbitrary messages;sendToUser()sends to the sessions of one user only. - Fire-and-forget:
sendFireAndForget(clientId, type, payload)— one-way message to an agent. - Fire-and-forget is the only direction the server needs:
REQUEST_STATE_UPDATEasks an agent to read now, and what comes back is an ordinaryKEEPALIVED_UPDATE.
SecretCrypto¶
Encrypts the secrets the server stores and has to read back — today the auth tokens of outbound clients (AES-256-GCM, iv:tag:ciphertext in hex). The key is derived via HKDF from secretKey in config.yaml and deliberately not from jwtSecret: rotating the session key must not make stored tokens unreadable. encryptSecret refuses while secretKey was generated on this start and could not be written back (secretKeyPersisted() in AppConfig). Tokens the server only has to recognise — inbound auth tokens, registration tokens — are hashed instead (hashToken).
ClientConnector¶
The server's side of outbound clients, the ones the server dials.
connectAll()— At startup, afterlisten(): dials every stored outbound client that has an auth token. One without a token cannot be retried here, because registering needs the agent's setup PIN (or secret) from the dashboard.firstConnect(id, address, secret, onPersist)— Adding a client: registers on the agent's/ws/register(handing over the setup PIN or secret, a fresh auth token and the server-issued id), then opens/ws/agent.onPersistwrites the client only onceAUTHhas succeeded; on failure nothing is stored and the reason from the handshake is returned.connectClient(client)— Decrypts the stored token (ClientRepository.outboundAuthToken); one that cannot be decrypted — usually a changedsecretKey— is logged and not dialled, without a reconnect. A regular session on<scheme>://<outboundTargetAddress>/ws/agent?clientId=…&token=…, with a 10-second connect timeout. The scheme comes from the stored address: an address writtenwss://host:portis dialled over TLS, a barehost:portover plaintext. One helper decides scheme and certificate handling for all three dial sites, so the query — which carries the auth token — is also what keeps it out of the log line. After an open socket the session is handed toWebSocketController.handleOutboundAgentConnection.- Before any registration — a new client or a re-registration — the server checks
secretKeyPersisted(): the token is stored encrypted only after the agent has accepted it, and failing at that point would leave an agent registered with a token the server never kept. scheduleReconnect(clientId)— Reconnects after 5, 10, 30 and then every 60 seconds, the same delays the agent uses for inbound connections. The ladder restarts once a socket opens.disconnectClient(clientId)— Cancels a pending reconnect and resets the ladder; used before deleting, reconnecting or re-addressing a client.
KeepalivedStateService¶
handleUpdate(clientId, payload)— OneKEEPALIVED_UPDATE: parsed againstKeepalivedStatusSchema(loose, so an agent that reports more is never dropped; an unknown state word becomesUNKNOWN), stored as the client's reading inkeepalived_state, and broadcast asKEEPALIVED_STATE_UPDATEwithclientIdandreceivedAt. A malformed reading is logged with the client id and the field and discarded; the last good one stays stored. A reading with instances also becomes the client'slast_instances; one of a stopped or unreadable keepalived keeps the previous ones, one of a running, readable keepalived without instances clears them. Where the reading has none, the state carries them aslastKnownInstances, which keeps the host in its clusters.getAll()/getByClientId(clientId)— The stored readings, parsed again on the way out so a row an older build wrote cannot reach the dashboard in a shape it does not expect.getClusters()—buildVrrpClustersfrom@kasm/sharedover every reading, the idsProxyServicehas connected and thesiteof every client, which is part of the cluster key (<site>|<vrid>|<network>— the virtual addresses are payload, not identity). The dashboard runs the same function over what it was pushed, so the endpoint and the page cannot disagree.delete(clientId)— With the client.- No failover logic here. The agent compares its readings itself and reports every change as an activity event, with keepalived's own transition time. That keeps a failover during a server outage on record, which a server-side diff between two readings could not.
ActivityService¶
Activity events are structured facts — kind, level, a subject, a data object — recorded by whoever observed them. Nothing in the backend writes a sentence of its own: the text is composed by activityMessage in @kasm/shared out of kind and data — for the dashboard and for the webhooks — so an agent of an older version stays useful and filtering by kind and level is exact rather than a search through prose.
list(userId)— Every event, newest first byoccurred_at, withseenas that user has it.record(input)— Records an event the server is the originator of. That is deliberately a short list: the connection state of an agent (client.connected/client.disconnected), a registration (client.registered), and a scheduler run that threw (scheduler.failed,error, withscheduler,triggeranderror). Everything that happens on a host is reported by that host. The one addition isClusterEventService, whose events describe a cluster as a whole, which no host can see.handleBatch(clientId, payload)— OneACTIVITYbatch from an agent: validated, ingested, then acknowledged withACTIVITY_ACK. Only the envelope is parsed as a whole; the events are parsed one by one. A batch whose envelope does not parse is dropped without an ack, so the agent keeps offering it — acknowledging what was never written would delete it on the only side that still had it. The one exception is an event that can never be stored: one that does not parse but carries an id is acknowledged without being stored and logged, because re-offering it changes nothing and the agent's in-order queue would stall behind it until its seven-day age limit.ingest(clientId, events)— Stores the batch and returns the ids the agent may drop.sourceandclientIdare overwritten from the connection: an agent may only ever speak about itself.- Both
recordandingestpass every event throughapplyLevelOverride(shared/src/activityLevelOverrides.ts) with theactivity_level_overridessetting before it is stored: an overridden kind is written with the configured level, and one set tononeis not written at all —recordthen returnsnull, andingeststill returns the id, so the agent drops the event instead of offering it again. This is the only place the setting is applied; the list, the dashboards and the webhooks read the level that was stored, and events stored earlier keep theirs. recordandingestbroadcastACTIVITY_APPENDEDwith the events they stored for the first time — never the full list, and never a repeat — and hand the same events toWebhookService.dispatch.markManySeen(ids, userId)— SendsACTIVITY_SEENwith the ids that turned seen to the sessions of that user only (ProxyService.sendToUser), and nothing when nothing changed.deleteAll— BroadcastsACTIVITY_UPDATEwith an empty list to every dashboard.
Delivery is at-least-once and the id comes from the originator, so a repeat is expected rather than an error: ActivityRepository.insertMany writes ON CONFLICT DO NOTHING inside one transaction, and the second copy of an event changes nothing. That is what puts a failover at three in the morning, with the server switched off, on record once the server is back.
ClusterEventService¶
Reports what happens to a VRRP cluster as a whole, through ActivityService.record, so the dashboards and the webhooks get it like any other event. The one place the server reports something it derived: under VRRP only the master speaks, so the state of every member is known only where every reading arrives. A cluster's state is two things, confirmed together in one row of vrrp_cluster:
- Who holds it —
clusterConditionfrom@kasm/shared(live = online and reporting): one MASTER, several, none. Its changes are the history:vrrp.master_changed,vrrp.split_brain,vrrp.master_lost, allinfo, withdata.previousMaster. -
How it is doing —
clusterIncidentStatefrom@kasm/shared: a health of its own, in which a member whose agent is offline does not count and a cluster without any connected agent isunreachable, plus the findings behind it, compared byincidentReasonsKey(type, host and subject, not a host's last state). Anything butokis an incident:vrrp.incident_opened/vrrp.incident_updated(warning),vrrp.incident_resolved(info), with the incident's id ascorrelationIdanddata.incident. The dashboard'scluster.healthis not used. -
start()— Subscribes toKeepalivedStateService.onReadingStoredandProxyService.onConnectionChange(listeners rather than calls, so neither service imports this one back), and starts the start-up guard: nothing is evaluated until every client with a keepalived reading has connected again, or 60 s after start. Called once inindex.ts, before any agent can connect. evaluate(trigger)— Takes oneClusterSnapshot(clusters, host names, when each reading arrived) and compares, per cluster, what it is seen as — role, master, incident health, findings — with its row. Where nothing can be said, the confirmed part is kept: the roles while no member is live, both parts while no live member is MASTER but an offline one last reported MASTER. Roles seen for the first time are taken silently; the health starts fromok. A difference is one pending state, owed a reading by every live member after the master down interval (3 ×advert_int+ 1 s) —unreachable, which no agent can pay for, after 60 s (UNREACHABLE_CONFIRM_MS). Once the time is up,PendingFollowUpsasks the members for their readings, or looks again where none owes one ({ recheck: true }). A pending state that goes away is dropped unsaid; a member that stops being live owes nothing. On confirmation, the events of the change are made at once from the difference between the old state and the new — roles first — all with the samedata.cluster(describeCluster). A cluster that disappears with an incident open getsvrrp.incident_resolvedwithresolution: "removed",data.clusteras the last confirmation saw it andincident.healthnull; its row is deleted. Errors are logged and never reach the reading or connection that triggered the evaluation.
ReadingRequests¶
Asks agents for the readings a pending cluster state waits for. An agent sends an unchanged reading on its own only every 30 s (RESEND_AFTER_MS), on top of its poll interval; asked, it reads at once and sends the reading regardless (KeepalivedService.refresh).
- requestReadings(clientIds) — Sends REQUEST_STATE_UPDATE to each connected client, at most once per client and second, since several clusters may wait for one reading. A client gone offline is skipped; offline, it owes nothing.
- PendingFollowUps — The one follow-up of each pending cluster state. schedule(clusterKey, since, afterMs, action) runs action at since + afterMs (at once if that is past); ClusterEventService passes the minimum age plus READING_REQUEST_MARGIN_MS (250 ms), so a requested reading is surely past it. Planning the same state again — keyed by since — changes nothing, so every evaluation can plan and the first one after a restart restores the plan, and a follow-up that ran does not run twice. cancel / cancelExcept drop the plans of states that were confirmed, dropped or whose cluster is gone. In memory only; a lost request just leaves the confirmation to the agent's own next reading.
WebhookService¶
Reports events to external targets, each with a JSON body template of its own (see Webhooks).
- dispatch(records) — Called with the events just stored, so a repeat from the agent's at-least-once delivery sends nothing twice. Returns at once; every enabled webhook whose minLevel and kinds an event passes gets a delivery queued behind it. Deliveries to one webhook run one after another, so a target sees events in order; past 100 waiting ones, new events are dropped for that webhook with a warning.
- A delivery renders URL, headers and body with renderTemplate from @kasm/shared, sends them with the webhook's timeout, and retries twice (after 1 s and 5 s) when nothing answered, or on a 5xx or 429. The outcome goes to last_status / last_error / last_attempt_at and, on failure, to the log — never to the activity list, or a broken target would report its own failures to itself. There is no persistent queue: a restart during a retry loses that delivery; the event itself stays stored.
- test(webhook) — Sends the sample event for the webhook's kinds once with a configuration that need not be saved, and returns what was sent and what came back. No retries, nothing recorded.
ScheduledJob¶
The timer, the bookkeeping and the status of one server scheduler; both — NotificationCleanupService and TokenCleanupService — hold one and differ only in the work they do.
- run(trigger, work) — Runs work as one recorded run (trigger is schedule or manual): SchedulerStateRepository.markStarted before, markFinished after, with success (or partial where a scheduler says so; none does today). A run that throws is stored as failed with its message, recorded as scheduler.failed in the activity, and the exception is rethrown. Broadcasts SCHEDULER_STATUS_UPDATE when a run starts and when it ends.
- start(runScheduled) / stop() — The timer is a chain of timeouts rather than an interval. The first run comes one interval after the last run started — at once if that is already past — so a restart does not push a due run back by a whole interval; a scheduler that has never run keeps the run it had planned (next_run_at), so restarting more often than the interval does not keep pushing its first run away. That run is capped at one interval from now, for an interval that was shortened; with none planned, the first run comes one interval after startup. An interval of 0 switches the timer off. Delays beyond what setTimeout takes (~24 days) are waited out in steps.
- status() — { isRunning, nextRun, lastRun }, lastRun read from scheduler_state.
Each service's run(trigger = "schedule") goes through its job; the settings controller passes "manual". startScheduler() / stopScheduler() / restartScheduler() and getStatus() delegate to it. At startup, index.ts calls SchedulerStateRepository.markInterrupted() before starting either of them.
NotificationCleanupService¶
Retention for the activity list. It is named after the settings it reads (notification_retention_days, notification_retention_count, notification_cleanup_interval_hours) and the page they are set on, "Activity History". Age is the event's own occurred_at, not its arrival time: a batch handed over after a week offline is a week old. Runs every notification_cleanup_interval_hours; returns { removed }.
TokenCleanupService¶
run(trigger?)— Removes registration tokens that have been invalid (used or expired) for longer thantoken_retention_days. Returns{ removed }.startScheduler()/stopScheduler()/restartScheduler()— Runs everytoken_cleanup_interval_hours(default24);0disables the scheduler. Restarted whentoken_retention_daysortoken_cleanup_interval_hourschanges.
SettingsService¶
Reads and writes the settings block of config.yaml and nothing else — security, jwtSecret and the OIDC credentials are startup configuration without an API.
- getAllSettings() — The settings block, every default filled in.
- getSetting(key) — One value as a string, or null when empty or not a scalar.
- updateSettings(settings) — Merges already validated keys into the block and writes the file. Then restarts the activity cleanup or the token cleanup when one of its keys changed.
4. Repositories (src/repositories/)¶
Repositories encapsulate all database queries using better-sqlite3 (synchronous).
| Repository | Tables accessed | Key operations |
|---|---|---|
ClientRepository |
clients |
Create inbound/outbound, lookup by id and token as a pair (findByIdAndToken), update display name, addresses, auth token, last_seen/version. |
TokenRepository |
registration_tokens |
Create with expiry and optional defaults, find a valid one, mark as used, delete, retention cleanup. |
UserRepository |
users |
CRUD, lookup by username, password hash management. |
KeepalivedStateRepository |
keepalived_state |
Upsert, list and delete the last reading per client. |
ActivityRepository |
activity, activity_seen |
Batch insert with primary-key dedup, seen state, deletion, retention. |
SchedulerStateRepository |
scheduler_state |
Mark a run started and finished, save and read the state a scheduler carries, turn runs cut off by a restart into interrupted ones. |
WebhookRepository |
webhooks |
CRUD, the enabled ones for a dispatch, the result of the last delivery. |
VrrpClusterRepository |
vrrp_cluster |
Read and upsert the confirmed row per cluster, delete the rows of clusters that are gone. |
HealthRepository |
— | SELECT 1 for GET /api/health; touches no table. |
5. WebSocket Controller (src/controllers/WebSocketController.ts)¶
Dashboard WebSocket (/ws/dashboard):
- Verifies the JWT from the kasm_session cookie of the handshake (4001 without or with an invalid one, and when its tv no longer matches the user — AuthService.isSessionCurrent).
- Sends on connect: CLIENTS_UPDATE, the stored KEEPALIVED_STATE_UPDATE of every client, and ACTIVITY_UPDATE with the seen state of the session's user.
- Attaches the 30-second ping/pong heartbeat before the JWT check.
- Registered in ProxyService to receive all broadcasts.
Heartbeat (src/controllers/websocket/Heartbeat.ts): all three connection kinds —
dashboard, inbound agent, outbound agent — use attachHeartbeat(socket, onTimeout?): a ping
every 30 seconds, terminate() when the previous pong never arrived. It registers its own
close handler, so a socket closed during authentication cannot leave the interval running.
Agent session (src/controllers/websocket/AgentSession.ts): attachAgentSession() runs
an agent connection from the AUTH handshake to the close, and both connection kinds go
through it. Each used to carry its own copy: the same timeout, the same Zod check, the same
register/record/broadcast sequence and the same close block. That copy spanned the point
where a client is marked online, so a difference between the two would have shown up as a
host that is connected on one route and not on the other. What genuinely differs is a
parameter — ip is stored and recorded inbound but null and omitted outbound,
onAuthenticated is the hook the outbound path creates a new client from, and
onAuthFailed carries the reason, so the inbound route still answers AUTH_FAILURE only
for a first message that is not AUTH. The heartbeat stays outside: the inbound route
attaches it before its credential checks, so a rejected connection loses its ping timer too.
Agent WebSocket (/ws/agent):
- Authentication: id + token resolved as a pair (findByIdAndToken; either half missing is 4001) → security.allowed_networks → outbound clients refused → per-client allowed address (skipped when switched off) → 5-second AUTH handshake.
- On success: updates last_seen, version and (inbound only) inbound_last_ip in the database; registers in ProxyService with the declared capabilities; answers AUTH_SUCCESS; broadcasts CLIENTS_UPDATE to all dashboards. The agent then pushes a fresh KEEPALIVED_UPDATE of its own.
- Outbound agents enter through handleOutboundAgentConnection over the socket ClientConnector opened, and from the handshake on run the same session as an inbound one. That path adds two things of its own: the first AUTH persists a newly added client, and a close schedules the reconnect.
- Incoming messages go through routeAgentMessage() (see below): KEEPALIVED_UPDATE → KeepalivedStateService.handleUpdate() (validate, persist, rebroadcast), ACTIVITY → ActivityService.handleBatch() (store, broadcast, ACTIVITY_ACK).
- Connecting, disconnecting and registering are recorded as client.connected, client.disconnected and client.registered. They are the events only the server can observe — an agent cannot report that it is unreachable.
- client.connected and client.disconnected are recorded at level trace, because they happen routinely. Both carry clientName in data — the display name, else the hostname — so the line names its host even after the host has been renamed or removed.
- On disconnect: unregisters from ProxyService; broadcasts updated client list.
Message routing (src/controllers/websocket/AgentMessageRouter.ts): what an
authenticated agent may send is one type → handler table, and both connection kinds
dispatch through it. The two branches used to stand once per kind although the messages are
identical in either direction, so a third type would have had to be added twice. An unknown
type is logged at debug and dropped — an agent of a newer build may know messages this
server does not.
The handshake is deliberately not in the table. AUTH is not something an authenticated
agent sends, and each connection kind ties its own side effects to it (clearing a timeout,
persisting a new client, resolving the caller's promise), which a table entry cannot carry.
🔁 Process Lifecycle (src/index.ts)¶
- Startup is fail-fast. Database migrations, OIDC discovery, the admin bootstrap,
listen()on the configured port (3010by default) and the initial outbound connections run first; any error there logs and exits with code 1. - Unhandled promise rejections are logged at
errorlevel and the process keeps running. A stray rejection must not drop every agent and dashboard connection. - Uncaught exceptions are logged at
fatallevel, the schedulers are stopped, and the process exits with code 1 after 250 ms (time for the pino transport to flush). The container supervisor restarts it (restart: unless-stoppedincompose.yaml). - Both handlers are registered only after startup completed, so they never hide a failed start.
SIGINT/SIGTERMstop the schedulers and close the server gracefully (exit code 0). A connection that does not finish closing is givenSHUTDOWN_TIMEOUT_MS(5 s); then the process exits anyway, before Docker's 10 s grace period would kill it. A second signal changes nothing.
📝 Logging¶
The logger lives in shared/src/node/logger.ts and is imported as @kasm/shared/node — by the backend and the client agent alike. The same loggerOptions object is handed to Fastify, so application lines and request logs share one format and one pino instance. The backend used to carry its own copy on pino@9 while Fastify resolved pino@10: two majors of the same library in one process.
LOG_FORMAT=jsonforces JSON,LOG_FORMAT=prettyforcespino-pretty; without it,NODE_ENV=productionmeans JSON and anything else pretty.LOG_LEVELsets the level;logLevelinconfig.yamlapplies when the variable is unset.@kasm/shared/nodeis a separate entry point on purpose. The frontend imports@kasm/shared, and anything Node-only exported from the main index would end up in the browser bundle. What needs Node goes behind/node; pure types, schemas and constants stay in the main index.pino-prettyis loaded by name inside pino's transport worker, not imported, so it has to stay a dependency ofsharedeven though no file references it.
🗄️ Database Management¶
The backend uses SQLite3 via better-sqlite3 (synchronous API) for fast, embedded storage.
- Location:
server/backend/data/server.db(created automatically on first run) —/app/server/backend/datain the image, whichcompose.yamlmounts as theserver-datavolume. - WAL mode: Enabled for improved read/write concurrency.
- Migrations: Managed by
umzug. All pending migrations are applied automatically on startup.
Schema¶
clients
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | Client UUID, issued by the server at registration. |
hostname |
TEXT | Client hostname. |
display_name |
TEXT | Optional human-readable name. |
auth_token |
TEXT | Permanent token for WebSocket authentication. (migration 11) Never the token itself: inbound its SHA-256 (hex), outbound encrypted with SecretCrypto (iv:tag:ciphertext). |
connection_mode |
TEXT | inbound (default) or outbound. |
inbound_allowed_ip |
TEXT | Inbound: the address or IPv4 network connections must come from. NULL switches the check off. |
inbound_last_ip |
TEXT | Inbound: the address of the last successful authentication. Written only after the allowed-address check passed. |
outbound_target_address |
TEXT | Outbound: host:port the server dials. |
version |
TEXT | Agent version reported on last connection. |
last_seen |
DATETIME | Timestamp of last successful connection. |
created_at |
DATETIME | Creation timestamp. |
updated_at |
DATETIME | Last update timestamp. |
KASM started from docker-instance-manager, whose migrations 00–16 are folded into
00_initialhere — without the Docker tables and columns, and without their history.
users
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Auto-incremented user ID. |
username |
TEXT UNIQUE | Unique username. |
password_hash |
TEXT | bcrypt-hashed password (null for OIDC-only users). |
auth_methods |
TEXT | Comma-separated: "local", "oidc", or both. |
created_at |
DATETIME | Creation timestamp. |
updated_at |
DATETIME | Last update timestamp. |
registration_tokens
| Column | Type | Description |
|---|---|---|
token_hash |
TEXT PK | SHA-256 (hex) of the random 32-character token. The token itself is only in the response that issued it (migration 04). |
created_at |
DATETIME | Creation timestamp. |
expires_at |
DATETIME | Expiry timestamp (30 minutes after creation). |
used_at |
DATETIME | Timestamp when a client registered with this token. |
display_name |
TEXT | Name the client is created under. NULL: the agent's hostname. |
allowed_ip |
TEXT | Allowed address or network for the client. NULL: the address it registers from. |
keepalived_state (migration 01)
| Column | Type | Description |
|---|---|---|
client_id |
TEXT PK | FK → clients(id), cascades on delete. No PRAGMA is needed: better-sqlite3 is built with SQLITE_DEFAULT_FOREIGN_KEYS=1; ClientController.delete removes the row explicitly as well. |
status |
TEXT | JSON: the agent's last KEEPALIVED_UPDATE as it passed KeepalivedStatusSchema. |
received_at |
TEXT | ISO timestamp the server received it. |
last_instances |
TEXT | JSON: the instances of the last reading that had any, without counters (migration 07). Kept while keepalived is stopped or unreadable; NULL once a running, readable keepalived reports none. |
One row per client, overwritten by every reading: the history of state changes is the activity list, reported by the agents themselves.
activity (migration 00)
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | Given by the originator. Delivery is at-least-once; the key is what makes a repeat a no-op. |
source |
TEXT | agent or server. |
client_id |
TEXT | Whose host this is about. NULL for events about nothing in particular. |
kind |
TEXT | e.g. vrrp.state_changed. Not constrained to the kinds this build knows. |
level |
TEXT | trace, info, warning or error. trace is routine bookkeeping the dashboard hides by default. |
correlation_id |
TEXT | Groups events that belong together, entered by whoever caused them. |
subject |
TEXT | JSON: VRRP instance name, VRID, interface. |
data |
TEXT | JSON: the facts of this kind — the old and the new state, priorities, an error. |
occurred_at |
TEXT | The originator's clock. Orders the list. |
received_at |
TEXT | The server's clock. Tells a late arrival from a recent event, and exposes a wrong agent clock. |
Indexed on occurred_at DESC and on correlation_id. There is no message column: the text
is composed out of kind and data by activityMessage in @kasm/shared, for the dashboard
and the webhooks alike.
activity_seen (migration 05)
| Column | Type | Description |
|---|---|---|
activity_id |
TEXT FK | → activity.id, ON DELETE CASCADE. |
user_id |
INTEGER FK | → users.id, ON DELETE CASCADE. |
Primary key (activity_id, user_id), WITHOUT ROWID. One row per event a user has seen, so
marking is a single INSERT OR IGNORE, and retention, "Delete all" and deleting a user clear
it away by themselves. Until migration 05 this was a JSON array in activity.seen_by; its
entries were moved over, except the ids of users that no longer exist.
scheduler_state (migration 03, next_run_at from 06)
One row per scheduler, written over on every run — there is no history. What is worth looking back on, a run that failed or was cut short, is in the activity list.
| Column | Type | Description |
|---|---|---|
scheduler |
TEXT PK | notification-cleanup, token-cleanup. |
running_since |
TEXT | Start of the run in progress; NULL when none runs. |
running_trigger |
TEXT | schedule or manual, for the run in progress. |
last_started_at |
TEXT | Start of the last finished run. |
last_finished_at |
TEXT | Its end; NULL for an interrupted run. |
last_trigger |
TEXT | schedule or manual. |
last_status |
TEXT | success, partial, failed or interrupted. |
last_result |
TEXT | JSON, { removed } for both schedulers. |
last_error |
TEXT | The message of a failed or interrupted run. |
state |
TEXT | JSON a scheduler carries from one run to the next; none uses it today. |
next_run_at |
TEXT | The run the timer has planned; NULL while the scheduler is off. Read at startup only while there is no last run. |
The running columns are kept apart from the
last_*ones so the page goes on showing the last finished run while a run is in progress. A row that still hasrunning_sinceat startup belongs to a run the server did not live to finish;markInterruptedturns it into the last run,interrupted.
vrrp_cluster (migration 09)
What ClusterEventService last confirmed per cluster — who holds it and how it is doing, in one row — so a restart neither reports every cluster anew nor misses a change that happened in between. Replaces vrrp_cluster_state and vrrp_cluster_incident, which were never released; migration 09 drops them.
| Column | Type | Description |
|---|---|---|
cluster_key |
TEXT PK | VrrpCluster.key: site, VRID and network. |
condition |
TEXT | Confirmed role: single, split-brain or no-master; NULL until anything could be said. |
master_client_id / master_instance / master_host |
TEXT | The last host that was master alone; kept while there is none, as the previousMaster of the next change. |
health |
TEXT | Confirmed incident health: ok, degraded, vip-mismatch, split-brain, no-master or unreachable. |
reasons |
TEXT | JSON: the confirmed findings, for the comparison and for added / cleared. |
incident_id |
TEXT | The open incident, its correlationId; NULL while ok. |
opened_at |
TEXT | When the open incident was first seen. |
history |
TEXT | JSON: every health the open incident was confirmed in, oldest first. |
last_cluster |
TEXT | JSON: data.cluster of the last confirmation, for a cluster that disappears. |
pending_state |
TEXT | JSON: what the cluster was seen as — role, master, health, findings key — not confirmed yet. |
pending_since |
TEXT | When it was first seen. |
pending_waiting |
TEXT | JSON: the members (clientId\ninstance) whose next reading is still owed. |
updated_at |
TEXT | Last write. |
webhooks (migration 08)
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | UUID. |
name |
TEXT | Shown in the list and available as {{webhook.name}}. |
enabled |
INTEGER | 1 or 0. |
url |
TEXT | Target, may contain placeholders. |
method |
TEXT | POST or PUT. |
headers |
TEXT | JSON object; values may contain placeholders. Stored in the clear. |
body_template |
TEXT | The JSON template as the operator wrote it. |
min_level |
TEXT | Lowest level sent. |
kinds |
TEXT | JSON array of kind patterns; empty means every kind. |
timeout_ms |
INTEGER | Per attempt. |
last_status |
INTEGER | HTTP status of the last delivery; NULL when nothing answered. |
last_error |
TEXT | Why the last delivery failed; NULL after a success. |
last_attempt_at |
TEXT | When it ended. |
created_at / updated_at |
TEXT | Timestamps. |
🔐 Authentication Flow¶
- Local Login: Username/password validated against bcrypt hashes in SQLite. On success the JWT is set as the httpOnly cookie
kasm_session, next to a readable flag cookiekasm_authwithout a secret (services/SessionCookie.ts). Both areSameSite=Strict,Securewhen the request came in over HTTPS, and expire with the token. The token is never part of a response body or a URL. - Session transport:
@fastify/cookieparses the cookie, and@fastify/jwtis registered withcookie: { cookieName: "kasm_session" }, sorequest.jwtVerify()accepts the cookie as well as anAuthorization: Bearerheader. The dashboard WebSocket reads the same cookie from the handshake. - OIDC Login: Full PKCE flow — the backend generates the authorization URL, handles the callback, exchanges the code for tokens, fetches userinfo from the provider, and issues a local JWT in the same cookies as the local login before redirecting to
/. - Agent Auth: Agents connect via WebSocket using a permanent
authToken(obtained during registration). The token is validated against the database and the source IP is checked against configured network rules. - First Run: If no users exist,
AuthService.initializeAdmin()creates anadminuser with the default password"admin". This should be changed immediately after first login.
⚙️ Configuration (src/config/AppConfig.ts)¶
The backend reads its configuration from server/config.yaml (and environment variables). The config is loaded at startup and written back when settings are updated via the API.
Validated at startup. After a missing jwtSecret and secretKey have been generated, the whole file is checked against AppConfigSchema from @kasm/shared, which also holds every default. An invalid value ends the start with exit code 1 and one fatal log line naming the field, for example:
Invalid config.yaml -- security.allowed_networks.0: Must be an IPv4 address or an IPv4 network in CIDR notation
Checked are types and value ranges: whole numbers and true/false in settings (as string or as plain YAML value), IPv4 addresses or networks in the security lists, hsts as a boolean, logLevel as a pino level, and — only while oidc.enabled is true — the OIDC URLs and credentials. The top level and settings stay loose: keys the schema does not know are kept, because the file is written back and would otherwise lose them. Defaults are written into the file only when a known settings key was missing, as before; a valid file is not rewritten on startup.
Key configuration sections:
| Section | Description |
|---|---|
jwtSecret |
Auto-generated on first run if not present. |
secretKey |
Auto-generated on first run if not present. Encrypts the outbound clients' auth tokens (SecretCrypto); separate from jwtSecret. |
jwtExpiresIn |
JWT session lifetime (e.g. "24h"). Defaults to "12h"; tokens always expire. Also enforced as maxAge on verification, so tokens issued without an expiry are retired by age. |
oidc |
OIDC provider settings (enabled, issuer, client_id, etc.). |
logLevel |
pino level; LOG_LEVEL wins when set. |
port |
Listen port (default 3010); KASM_SERVER_PORT wins when set. |
settings |
Operator settings (stored as strings, defaults in AppSettingsSchema): token_*, notification_*. See Get Settings. |
security.allowed_networks |
IPv4 addresses or CIDR ranges permitted to connect as agents. The per-client address lives in clients.inbound_allowed_ip, editable via PUT /clients/:id; network matching is @kasm/shared's network.ts, shared with the agent and the client editor. |
security.hsts |
Send Strict-Transport-Security (default false). Read at startup. |
security.allow_self_signed_agent_certificates |
Accept an outbound agent's certificate that does not verify, for wss:// target addresses (default false). |
📦 Key Dependencies¶
| Package | Version | Purpose |
|---|---|---|
fastify |
^5.x | HTTP framework |
@fastify/websocket |
^11.x | WebSocket support |
@fastify/jwt |
^10.x | JWT middleware |
@fastify/cookie |
^11.x | Session cookie parsing |
@fastify/cors |
^11.x | Registered with origin: false — no CORS headers (same-origin only) |
@fastify/static |
^10.x | Frontend static file serving |
@fastify/rate-limit |
^11.x | Login rate limit (10 attempts / 15 min, no global limit) |
@fastify/helmet |
^13.x | Security headers incl. Content-Security-Policy; HSTS only with security.hsts |
better-sqlite3 |
^13.x | Synchronous SQLite3 |
umzug |
^3.x | Database migration management |
bcryptjs |
^3.x | Password hashing |
openid-client |
^6.x | OIDC / PKCE client |
yaml |
^2.x | Config file parsing |
@kasm/shared/node |
workspace | Pino logger (logger, loggerOptions), shared with the agent — see Logging |
@kasm/shared |
workspace | Types, Zod schemas and buildVrrpClusters, shared with the agent and the dashboard |