⚙️ Backend Architecture¶
This documentation details the architecture of the server backend (server/backend), which serves as the control plane for the Docker Instance Manager.
📂 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
│ ├── DockerController.ts # Docker state, actions, image update checks
│ ├── ProjectController.ts # Query-defined container groups DIM carries a setting for
│ ├── SettingsController.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
│ └── migrations/
│ ├── 00_initial.ts # Initial database schema
│ ├── 01_docker_state.ts # docker_state table
│ ├── 02_image_update_checks.ts # image_update_checks table
│ ├── 03_image_update_checks_drop_columns.ts
│ ├── 04_container_auto_update.ts # container_auto_update_manual table (dropped again in 12)
│ ├── 05_notifications.ts # notifications table (dropped again in 13)
│ ├── 06_connection_mode.ts # clients.connection_mode
│ ├── 07_rename_inbound_allowed_ip.ts
│ ├── 08_token_registration_defaults.ts # display name and allowed address per token
│ ├── 09_inbound_last_ip.ts # clients.inbound_last_ip
│ ├── 10_notification_steps.ts # correlated steps, carried into activity by 13
│ ├── 11_projects.ts # projects table (by Compose name, superseded by 16)
│ ├── 12_drop_manual_auto_update.ts # drops container_auto_update_manual
│ ├── 13_activity.ts # activity table; drops notifications
│ ├── 14_client_auto_update_cron.ts # clients.auto_update_cron
│ ├── 15_activity_trace_level.ts # the trace level on activity
│ ├── 16_project_queries.ts # projects rebuilt around id and query
│ ├── 17_image_update_checks_platform.ts # image_update_checks keyed by platform and local digest
│ ├── 18_image_update_check_labels.ts # remote_labels on image_update_checks
│ ├── 19_scheduler_state.ts # scheduler_state: last run and state per scheduler
│ ├── 20_registration_token_hash.ts # registration_tokens: store the SHA-256 hash only
│ ├── 21_activity_seen.ts # activity.seen_by -> activity_seen table
│ ├── 22_client_timezone.ts # clients.timezone reported by the agent
│ ├── 23_scheduler_next_run.ts # scheduler_state.next_run_at: the planned run
│ ├── 26_webhooks.ts # webhooks: targets events are reported to
│ ├── 27_client_auto_update_runs.ts # clients: last and next auto-update run, backfilled
│ └── 28_client_registration_token.ts # clients: registration token per client; drops registration_tokens
├── repositories/ # Database access layer
│ ├── ActivityRepository.ts # activity access (insert, dedup, retention)
│ ├── ClientRepository.ts
│ ├── DockerStateRepository.ts # docker_state + image_update_checks access
│ ├── ProjectRepository.ts # projects access
│ ├── SchedulerStateRepository.ts # scheduler_state access
│ ├── UserRepository.ts
│ └── 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
│ ├── DockerStateService.ts # Persist/retrieve Docker state snapshots
│ ├── ActivityService.ts # Activity ingest, dedup, ack + dashboard broadcast
│ ├── NotificationCleanupService.ts # Retention cleanup for the activity list
│ ├── ImageUpdateCacheCleanupService.ts # Scheduled image_update_checks cleanup
│ ├── ImageUpdateCheckSchedulerService.ts # Periodic registry update sweep
│ ├── ScheduledJob.ts # Timer, run bookkeeping and status of a scheduler
│ ├── AutoUpdatePolicyService.ts # Resolves the auto-update policy and sends it to the agents
│ ├── AutoUpdateRunService.ts # Asks agents to run, reads back what they did
│ ├── ProxyService.ts # WebSocket connection management & broadcasting
│ ├── SettingsService.ts # Settings retrieval, update & persistence
│ └── 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 24). 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 DIM 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/inbound, POST /api/v1/clients/outbound, PUT/DELETE /api/v1/clients/:clientId, POST /api/v1/clients/:clientId/reconnect, POST /api/v1/clients/:clientId/auto-update/run, POST /api/v1/clients/:clientId/registration-token
- Docker: GET /api/v1/clients/:clientId/docker, POST /api/v1/clients/:clientId/docker/action, POST /api/v1/clients/:clientId/docker/refresh, GET /api/v1/docker/images/check-update
- Settings: GET/PUT /api/v1/settings/cleanup, POST /api/v1/settings/cleanup/{image-version-cache,notifications}, GET /api/v1/settings/scheduler-status, POST /api/v1/settings/image-update-check/run, POST /api/v1/settings/container-auto-update/validate-cron, GET /api/v1/settings/container-auto-update/label
- Projects: GET/POST /api/v1/projects, POST /api/v1/projects/preview, PATCH/DELETE /api/v1/projects/:id
- 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 dim_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 @dim/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, a cron expression has to be valid) 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 inbound client (which then waits for its agent) or an outbound one, issuing a registration token, the agent's self-registration (register), editing display name, allowed address, target address and auto-update schedule, deletion, reconnecting an outbound client, asking one agent to run its auto-update. |
DockerController |
Docker state retrieval, action dispatch to agents, image update checks. Records action.requested under the action's id, and action.unconfirmed when no answer comes back (connection lost, timeout). The outcome itself is the agent's; the server records action.failed only for an agent too old to report it. |
ActivityController |
The activity list, per-user seen state, deletion of all of it. |
ProjectController |
Project list, query preview, create/update/delete with the one-project-per-container check (409). |
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. - Live agent status:
handleAgentStatus(clientId, payload)takes anAGENT_STATUS. The last and next auto-update run go into the database (ClientRepository.updateAutoUpdateState); a run under way and the Docker daemon's reachability stay in memory with the connection (liveStatus) and are dropped with it, because only a connected agent can say them.CLIENTS_UPDATEfollows only when something changed. - Status enrichment:
getClientsWithStatus()— augments database records with live online/offline status, the capabilities and the live agent status (autoUpdateRunningSince,docker;nullwhile offline). - Broadcasting:
broadcastClientUpdate()sendsCLIENTS_UPDATEto all dashboards;broadcastToDashboard()multicasts arbitrary messages;sendToUser()sends to the sessions of one user only. - Messages to an agent:
sendToAgent(clientId, type, payload)— one-way message to a connected agent, throws when it is not;sendOn(socket, type, payload)— the same on a given socket, also one whose handshake is not finished (AUTH_SUCCESS,AUTH_FAILURE). Both are typed againstServerMessagesfrom@dim/shared. - Docker state:
handleDockerUpdate(clientId, state)persists the snapshot viaDockerStateServiceand rebroadcasts it asDOCKER_STATE_UPDATEto all dashboards. - Docker actions:
requestDockerAction(clientId, action, timeoutMs = 120_000)sends aDOCKER_ACTIONand resolves with the agent'sDOCKER_ACTION_RESULT. Each pending action remembers the client and the socket it went out on: the agent answers over that socket, so when it closes — disconnect, or a new connection replacing it — the action fails at once instead of after two minutes. A failure is aDockerActionErrorwithreasonnot-connected,disconnectedortimeout. A result is only accepted from the client the action was sent to. Results are also rebroadcast to dashboards.
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 in ClientRepository).
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.
DockerStateService¶
update(clientId, state)— Upserts the snapshot in thedocker_statetable and returns the storedDockerState(withupdatedAt).getByClientId(clientId)— Returns the last persisted state, ornull.- Nothing else. It used to diff the new snapshot against the previous one to report container changes; the agent reports what it sees on the Docker event stream instead, which carries an exit code, an OOM kill, a health transition and the operation that caused them — none of which exists in the difference between two snapshots.
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 @dim/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), the request of an action a user asked for and, if its answer never came, that it is still pending (action.requested/action.unconfirmed;action.failedonly on behalf of an agent too old to report the outcome itself), an image update sweep the registry cut short (imagecheck.interrupted), and a scheduler run that threw (scheduler.failed,error, withscheduler,triggeranderror). Everything that happens on a host is reported by that host.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.ingestfirst marks the exits somebody asked for (markRequestedExits,shared/src/requestedExits.ts): acontainer.diedthat followed acontainer.killedof the same container within a minute, with no start, removal or other exit in between, is stored at no more thaninfoand withdata.requested: true. Docker reports a kill whenever a client of its API ends a container —docker stop,rm -f,compose down, a recreate, and every stop DIM sends — and never when the container ends by itself or runs out of memory. The batch is read in the order of its clocks; an exit whose container has no earlier step in the batch is decided byActivityRepository.answersSignal, which looks at the stored events of that container in the minute before. This runs ahead of the level overrides, so the overrides, the dashboards and the webhooks all see the same level, and an override ofcontainer.diedstill has the last word. The agent remembers nothing for it: it reports both events as Docker sends them.
Both record and ingest pass every event through applyLevelOverride (shared/src/activityLevelOverrides.ts) with the activity_level_overrides setting before it is stored: an overridden kind is written with the configured level, and one set to none is not written at all — record then returns null, and ingest still 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.
- record and ingest enter subject.projectIds before storing, resolved by ProjectService.activityProjectResolver() once per batch: the projects of the container (by id, else by name, in its host's last state; a container that is gone is matched from the name and image in the event), or of every container on that host running the image the event is about. Projects the originator named are kept. The value is fixed at storage time, so a later query change does not move old events.
- record and ingest broadcast ACTIVITY_APPENDED with the events they stored for the first time — never the full list, and never a repeat — and hand the same events to WebhookService.dispatch.
- markManySeen(ids, userId) — Sends ACTIVITY_SEEN with the ids that turned seen to the sessions of that user only (ProxyService.sendToUser), and nothing when nothing changed.
- deleteAll — Broadcasts ACTIVITY_UPDATE with 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 makes an unattended run at three in the morning, with the server switched off, fully accounted for once the server is back.
Correlation¶
correlation_id is entered by whoever caused the group, never worked out by the receiver:
- An action from the dashboard is sent with an
actionIdthe server generates. The server recordsaction.requestedunder it, and the agent stamps the same id on every container and image event the action goes on to cause. The agent knows which containers it is about to touch before it touches them, because it is the side doing the work. It also reports the outcome under that id (action.completed/action.failed, with the error), through its queue: a connection that closes mid-action loses theDOCKER_ACTION_RESULT, but not the outcome. The server then records onlyaction.unconfirmed, which the dashboard drops as soon as the agent's outcome arrives. - An auto-update run (from the agent, once it runs its own) carries a
runIdon every event it causes and on the closingautoupdate.run.
This replaces the old NotificationGroupService, which matched a change to an operation by container name inside a 20-second window because the server only ever saw the result. Nothing matches names any more, nothing depends on arrival order, and an event delayed by an offline stretch still lands in its group hours later.
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 @dim/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 client comes from ClientRepository, the names behind subject.projectIds ({{event.projects}}) from ProjectRepository at that moment. 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; all three — ImageUpdateCheckSchedulerService, ImageUpdateCacheCleanupService, NotificationCleanupService — 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 the scheduler says so (the image update check, when a registry paused it). 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 no longer pushes 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 any of them.
NotificationCleanupService¶
Retention for the activity list. It keeps its old name because the settings it reads (notification_retention_days, notification_retention_count, notification_cleanup_interval_hours) are stored values. 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 }.
ImageUpdateService (from @dim/shared/node)¶
Lives in shared/src/node/imageUpdate.ts, not in services/: the agent asks the same registries the same question once it updates its images on its own, and the module needs nothing but fetch and the logger.
- checkForUpdate(repoTag, repoDigests, platform?) — Parses the image reference, authenticates against the registry (Docker Hub, ghcr.io, lscr.io) and fetches the manifest digest via a HEAD /v2/{name}/manifests/{tag} request. The local repoDigest is the digest of an index that covers every platform, so with platform (the one the local image was built for) the answer is about that platform only: equal index digests are answered by the HEAD request alone, so an image without an update costs one counted request; no image for it in the registry means no update and an error; differing index digests are resolved to the entries for the platform in the old index (fetched by the local digest) and the new one, and only a changed entry is an update. If the registry no longer has the old index, the change counts as an update. platform.variant is not looked at. Without platform the index digests are compared as they are. A request the registry refused is reported with its status in error (Registry rate limit reached (429), Registry denied access (401), Tag not found in registry (404), Registry request failed (HTTP 500), Registry unreachable), and a rate limit additionally sets rateLimited and retryAfterSeconds (the Retry-After header in seconds or as an HTTP date, RATE_LIMIT_FALLBACK_SECONDS = 3600 without one), which is what lets a sweep pause the registry instead of asking on. ratelimit-remaining is passed on as rateLimitRemaining where the registry sends it. Only when an update is found are the org.opencontainers.image.* labels of the new image read (remoteLabels): the platform manifest is one more counted request, the config blob none at Docker Hub. The rule that a missing old index counts as an update does not apply to a refused request: a registry that said nothing has not said the index is gone. Returns { repoTag, localDigest, remoteDigest, hasUpdate, platform?, remotePlatformDigest?, remoteLabels?, error?, rateLimited?, retryAfterSeconds?, rateLimitRemaining? }.
- fetchManifestCreatedDate(repoTag, platform?) — The build date of the remote image for platform (null if the registry has none), used by the auto-update delay check. Without platform, linux/amd64 is preferred on a manifest list.
ImageUpdateCacheCleanupService¶
run(trigger?)— Removes orphanedimage_update_checksrows (rows whoseimage_refis no longer referenced by any client state, or whoselocal_digestno client holds any more) and rows older thanimage_version_cache_ttl_days. Returns{ orphansRemoved, expiredRemoved }.startScheduler()/stopScheduler()/restartScheduler()— Runsrun()everyimage_version_cache_cleanup_interval_hours.0disables the scheduler. Automatically restarted when anyimage_version_cache_*setting changes.
ImageUpdateCheckSchedulerService¶
run(trigger?)— Sweeps every image a container runs, once per tag, platform and local digest (DockerStateRepository.getImageCheckTargets), grouped by registry host (registryOf), callsImageUpdateService.checkForUpdatewith the image's platform, and persists the result. Returns how many targets it asked about; the stored result is{ checked, total, pausedRegistries }, and a run that paused a registry ispartial. A sweep costs one counted registry request per image, and one more per image whose update it sees first. A rate limit pauses its registry, not the sweep. A limit belongs to the registry host — Docker Hub counts per IP or account across every repository — so once a check comes backrateLimited, the rest of that registry's targets are written without asking:DockerStateRepository.recordImageCheckSkippedstores the error and the timestamp and leaveshas_updateandremote_digestas they are, so the detail pages say why they got no newer answer while the update indicator keeps the last real one. The registry is then paused forretryAfterSeconds; the other registries are asked as before, and their images never get the rate-limit note. The run recordsimagecheck.interrupted(warning) withregistry,checked,total,errorandretryAfterSeconds. Paused registries are skipped. Scheduled runs leave a registry alone until its pause is over, and skip the run entirely while every registry is paused. The manual run (triggermanual) asks all of them and pauses a registry again if it refuses. A run skipped because every registry is paused is not recorded. The registry state survives a restart. Pauses, resume points, last checks and errors are saved toscheduler_state.stateafter every sweep and read back once, on first use after startup. Each registry resumes where it stopped. The first target left unasked is remembered per registry (byimageCheckTargetKey) and moved to the front of that registry's group in the next run, so a limit that returns every run does not keep asking about the same head of the list and never about its tail. A target that no client reports any more puts the group back on its natural order.getStatus()—isRunning,nextRun,lastRunandregistries: oneRegistryStatusper registry host of the current targets, with its target count, how many the last sweep asked, the last check, the pause, the lastratelimit-remainingand the error.startScheduler()/stopScheduler()/restartScheduler()— Interval driven byimage_update_check_interval_seconds.0disables.
AutoUpdateRunService¶
The server's half of an auto-update it no longer performs. ContainerAutoUpdateSchedulerService is gone: it resolved eligibility from stored snapshots, asked the registries on the agents' behalf and dispatched one image:update action per container — all of which the host answers better, and none of which worked while the host was unreachable. What is left is configuration, a command, and observation.
trigger(clientId)— SendsAUTO_UPDATE_RUNto one agent. It carries no list of containers: the host holds the better one. Returns whether the agent was asked, not what came of it — the run reports itself through its events. There is no fleet-wide counterpart any more: atriggerAll()could only answer with the number of commands sent, while the answer worth having is the report each host sends back.handleImageChecks(clientId, payload)— Takes an agent'sIMAGE_CHECKS(the registry answers of the last run of each of its schedules, checked againstImageChecksSchema) intoimage_update_checks, throughupdateImageCheckResultIfNewer. The table stays the dashboard's source for the update indicator, and a host that has just asked about its own images knows the answer before the server's own sweep comes round; the guard keeps the same answers sent again on the next connect, or a late report, from ageing a fresher result.
AutoUpdatePolicyService¶
buildFor(clientId)— TheAUTO_UPDATE_POLICYfor one host: the enrolment label, the delay label, this host's schedule and every project with its schedule. Every expression is already resolved, so the agent never sees anulland never has to know the inheritance rules.- The inheritance lives here and nowhere else: the default from
container_auto_update_cron, then the host'sclients.auto_update_cron, then the project'scron.NULLmeans "inherit" at every level. A host whose expression is empty takes part through its projects only — that is a statement about what is outside them, so a project without a schedule of its own falls back to the default rather than inheriting the emptiness and switching itself off with it. - Every project is in the list,
autoUpdate: falseones included: a container may be enrolled through its label while belonging to a project, and the project is what decides when it is updated. Each carries its query, which the agent evaluates itself against thehostidentity the policy carries (hostname and display name as the server stores them). Agents that did not declareproject-queryget an empty list: they would read a project as a Compose stack name. sendTo(clientId)/broadcast()— Sends it to one agent or to all connected ones. Only to agents that declared theauto-updatecapability in theirAUTH; the rest would store something they never read. Called afterAUTH_SUCCESS, after a change to one of the three settings the policy is built from, after any change to a project (which is global by definition), and after a client's own schedule is saved.readAutoUpdateLabel()/readDelayLabelKey()— The label settings, parsed, on their way into the policy. The agents resolve the labels themselves from that point on; nothing on the server reads them to decide anything.
ProjectService¶
activityProjectResolver()— Reads the projects and host states once and returns a function that maps an event's host and subject to project ids; used byActivityServiceto stamp events.listResponse()— The managed projects, each with the clients, containers and distinct images assigned to it right now.hostStates()— Every reported host with its containers and the identity (hostname, display name) a query is matched against.- Membership is never stored. It is resolved with
resolveAssignmentfrom@dim/shared— the same function the dashboard and the agents use. A container that matches several queries is a conflict: it counts as a member of each project and in theirconflictCount, and the agents update it through none of them. conflictsOf(query, excludeId)/preview(query, excludeId)— The containers a query would share with other projects, and what it matches. Create and update refuse a query with conflicts (409).normaliseCron(expr)— An empty expression is not a schedule but the absence of one, and becomesnull("inherit").validateCron(expr)— Validates a cron expression vianode-cron. It lives here because a project's schedule is the reason the server still knows about cron at all; it runs none of them.broadcast()— SendsPROJECTS_UPDATEwith the full list response.
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 acts on what changed: restarts the cache cleanup, the image-check sweep or the activity cleanup when one of their keys changed, broadcasts AUTO_UPDATE_LABEL_UPDATE for a new label, and sends every agent a fresh policy when one of the three container_auto_update_* keys changed.
4. Repositories (src/repositories/)¶
Repositories encapsulate all database queries using better-sqlite3 (synchronous).
| Repository | Tables accessed | Key operations |
|---|---|---|
ClientRepository |
clients |
Create a waiting inbound client or an outbound one, set a registration token, find the client a valid token belongs to and complete its registration in one statement (completeRegistration), lookup by id and token as a pair (findByIdAndToken), update display name, addresses, schedule, auth token, last_seen/version. hashToken is the SHA-256 every token the server only recognises is stored as. |
UserRepository |
users |
CRUD, lookup by username, password hash management. |
DockerStateRepository |
docker_state, image_update_checks |
Upsert/query Docker snapshots; cache and clean up image checks. updateImageCheckResultIfNewer takes the answers an agent reported. |
ProjectRepository |
projects |
List/add/update/remove a project with its query. |
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 left in progress into interrupted at startup. |
WebhookRepository |
webhooks |
CRUD, the enabled ones for a dispatch, the result of the last delivery. |
5. WebSocket Controller (src/controllers/WebSocketController.ts)¶
Dashboard WebSocket (/ws/dashboard):
- Verifies the JWT from the dim_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 DOCKER_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, timezone and (inbound only) inbound_last_ip in the database; registers in ProxyService with the declared capabilities; answers AUTH_SUCCESS; sends the agent its AUTO_UPDATE_POLICY (from the shared session, because both directions send it at the same point and for the same reason); broadcasts CLIENTS_UPDATE to all dashboards. The agent then sends each of its states — DOCKER_UPDATE, AGENT_STATUS, IMAGE_CHECKS — and offers its activity queue.
- 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): DOCKER_UPDATE → ProxyService.handleDockerUpdate() (persist + rebroadcast), DOCKER_ACTION_RESULT → ProxyService.handleDockerActionResult() (resolve pending promise + rebroadcast), ACTIVITY → ActivityService.handleBatch() (store, broadcast, ACTIVITY_ACK), AGENT_STATUS → ProxyService.handleAgentStatus() (runs persisted, live status kept with the connection, CLIENTS_UPDATE only when something changed), IMAGE_CHECKS → AutoUpdateRunService.handleImageChecks() (into the update-check cache).
- 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 (migration 15 moved the ones already stored), 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.
- Both payloads are validated first (DockerUpdatePayloadSchema, DockerActionResultSchema from @dim/shared). The update schema checks only what the server reads — container id, names, image, state, labels; image id, repoTags, repoDigests; volume and network names — and lets every other field through, so an agent that reports more is never dropped. A malformed message is logged with the client id and the field and discarded; the last good state stays stored.
- 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 (3000by 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. The schedulers run async jobs on their own timers, and 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).- The SPA is served from
server/dist/publicwith a fallback toindex.htmlfor client-side routes — but not under/assets/: a missing chunk answers404. A tab still running the previous build then gets an error instead of HTML parsed as a module, and the frontend reloads once onvite:preloadErrorto fetch the newindex.html(Main.tsx, guarded by a time stamp insessionStorageagainst a reload loop).
📝 Logging¶
The logger lives in shared/src/node/logger.ts and is imported as @dim/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.@dim/shared/nodeis a separate entry point on purpose. The frontend imports@dim/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 — for an inbound client when its first token is issued, for an outbound one at registration. |
hostname |
TEXT | Client hostname. NULL until an inbound client's agent has registered. |
display_name |
TEXT | Optional human-readable name. |
auth_token |
TEXT | Permanent token for WebSocket authentication. (migration 25) Never the token itself: inbound its SHA-256 (hex), outbound encrypted with SecretCrypto (iv:tag:ciphertext). NULL for an inbound client that waits for its first agent. |
connection_mode |
TEXT | (migration 06) inbound (default) or outbound. |
inbound_allowed_ip |
TEXT | (migrations 06 / 07) Inbound: the address or IPv4 network connections must come from. NULL switches the check off — except on a client that has never registered, where it means that the address the agent registers from becomes it. |
inbound_last_ip |
TEXT | (migration 09) Inbound: the address of the last successful authentication. Written only after the allowed-address check passed. |
outbound_target_address |
TEXT | (migration 06) Outbound: host:port the server dials. |
version |
TEXT | Agent version reported on last connection. |
auto_update_cron |
TEXT | (migration 14) This host's auto-update schedule for containers in no project. NULL inherits the default from the settings; '' means the host takes part through its projects only — the two are deliberately different values. |
auto_update_last_run |
TEXT | (migration 27) When the host last finished an auto-update run, on the agent's clock. Only ever moved forward, by AGENT_STATUS; the migration backfilled it from the newest autoupdate.run of each client. |
auto_update_next_run |
TEXT | (migration 27) The earliest run the host's schedules have planned, as the agent last reported it. |
registration_token_hash |
TEXT UNIQUE | (migration 28) Inbound: SHA-256 (hex) of the registration token issued for the client, NULL when none is open. The token itself is only in the response that issued it. Cleared by the registration that uses it; a new token replaces it. |
registration_expires_at |
DATETIME | (migration 28) Until when that token is valid (30 minutes after it was issued), ISO text. Kept after it has expired, so the page can say so. |
last_seen |
DATETIME | Timestamp of last successful connection. NULL for an inbound client that waits for its first agent. |
created_at |
DATETIME | Creation timestamp. |
updated_at |
DATETIME | Last update timestamp. |
Migration 06 rebuilt
clientsand folded the originalallowed_ip/ip_addresscolumns into one; migration 07 renamed it toinbound_allowed_ip. Migration 28 dropped theregistration_tokenstable: a registration token is a property of the client it registers, and nothing is left behind to clean up once it is used or replaced.
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. |
docker_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. The same holds in migrations — one that drops and rebuilds clients deletes every stored state with it. |
containers |
TEXT | JSON-encoded DockerContainer[]. |
images |
TEXT | JSON-encoded DockerImage[]. |
volumes |
TEXT | JSON-encoded DockerVolume[]. |
networks |
TEXT | JSON-encoded DockerNetwork[]. |
updated_at |
DATETIME | Timestamp of the most recent snapshot. |
image_update_checks (migration 17, replacing the table of migrations 02 / 03 without carrying its rows over; remote_labels since migration 18)
| Column | Type | Description |
|---|---|---|
image_ref |
TEXT PK | Image reference (e.g. nginx:latest). |
platform |
TEXT PK | os/architecture of the local image; empty for an agent that does not report it. |
local_digest |
TEXT PK | The local index digest the answer is about; empty for an image without a repoDigest. |
has_update |
INTEGER | The verdict of the platform-aware check. |
remote_digest |
TEXT | Index digest fetched from the registry. |
checked_at |
TEXT | ISO 8601 timestamp of the last check. Used by the cache TTL cleanup. |
error |
TEXT | Error message if the last check failed. |
remote_labels |
TEXT | JSON object of the new image's org.opencontainers.image.* labels, fetched only for an image with an update; NULL when never fetched. |
The same tag is a different image on every platform, and a verdict only holds for the index it was answered about, so a row is read back only for an image with the same tag, platform and local digest. A re-pulled image stops matching its old row by itself; nothing is invalidated on a new state.
remote_labelsbelongs toremote_digest, which is not part of the key: a write without labels keeps the stored ones while the remote digest stays the same, and drops them once it names another.
scheduler_state (migration 19, next_run_at from 23)
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 | image-update-check, image-cache-cleanup, notification-cleanup. The row of the removed token-cleanup was deleted by migration 28. |
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, per scheduler ({ removed }, { orphansRemoved, expiredRemoved }, { checked, total, pausedRegistries }). |
last_error |
TEXT | The message of a failed or interrupted run. |
state |
TEXT | JSON the scheduler carries from one run to the next; only the image update check has any ({ registries }). |
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 during a long sweep. 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.
projects (migration 16, replacing the table of migration 11 without carrying its rows over)
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | UUID. |
name |
TEXT UNIQUE | Display name, free to choose. |
query |
TEXT | JSON: the criteria that decide membership (see Projects). |
auto_update |
INTEGER | 0/1. Switches auto-update for every container of the project. |
cron |
TEXT | Schedule. NULL means inherit the default from the settings, not off. |
created_at |
TEXT | ISO timestamp the entry was added. |
Membership is deliberately absent: which containers belong to a project is resolved from the query against what the agents report, so a container that stops matching leaves the project without anything being cleaned up.
activity (migration 13)
| 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. container.died. 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 | The run or action that caused this, entered by whoever caused it. |
subject |
TEXT | JSON: container name/id, image reference, the projects it was about (projectIds). |
data |
TEXT | JSON: the facts of this kind — an exit code, a health status, a run's counts. |
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 @dim/shared, for the dashboard
and the webhooks alike.
activity_seen (migration 21)
| 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 21 this was a JSON array in activity.seen_by; its
entries were moved over, except the ids of users that no longer exist.
webhooks (migration 26)
| 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. |
notifications(migrations 05 / 10) held server-written sentences and was dropped by migration 13 without carrying anything over. A notification is the result of comparing two snapshots; there is no way to read a kind, a level, a subject and a correlation back out of a finished sentence.
container_auto_update_manual(migration 04) held the manual per-container enrollments and was dropped again by migration 12. Its entries were not carried over: they name single containers, and the only thing left to enrol them with is their Compose stack — which holds more containers than were ever on the list.
🔐 Authentication Flow¶
- Local Login: Username/password validated against bcrypt hashes in SQLite. On success the JWT is set as the httpOnly cookie
dim_session, next to a readable flag cookiedim_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: "dim_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 @dim/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 3000); DIM_SERVER_PORT wins when set. |
settings |
Operator settings (stored as strings, defaults in AppSettingsSchema): token_*, image_version_cache_*, image_update_check_interval_seconds, container_auto_update_*, 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 (migration 07), editable via PUT /clients/:id; network matching is @dim/shared's network.ts, shared with the agent and the client editor. |
security.hsts |
Send Strict-Transport-Security (default false). Read at startup. |
📦 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 |
node-cron |
^4.x | Validating cron expressions — the server runs none; the schedulers use intervals |
yaml |
^2.x | Config file parsing |
@dim/shared/node |
workspace | Pino logger (logger, loggerOptions) and ImageUpdateService, shared with the agent — see Logging |