🤖 Client Agent Architecture¶
This documentation details the architecture of the Node.js client agent (client/), which runs on the machines that are being backed up.
💻 Platform Support¶
The client agent supports multiple architectures:
- x86_64 (amd64): Standard Docker image
pbcm-client. - ARM64 (aarch64): Dedicated Docker image
pbcm-client-arm64, optimized for devices like Raspberry Pi.
📂 Project Structure¶
The client is a lightweight, headless Node.js process designed to run as a daemon (either via systemd or Docker).
client/src/
├── core/ # Base services: config, data files, WebSocket, process-wide helpers
│ ├── CappedLog.ts # Head-and-tail bounded capture of a run's output
│ ├── Config.ts # config.yaml, validated once and frozen
│ ├── ConfigFile.ts # The YAML document itself, edited with the operator's comments kept
│ ├── Connection.ts # WebSocket client with auto-reconnect
│ ├── DataStore.ts # Data directory, atomic JSON writes, setting damaged files aside
│ ├── Identity.ts # clientId + authToken in identity.json, and their move out of config.yaml
│ ├── ServerHttp.ts # Requests to the PBCM server, certificate check decided per call
│ ├── LegacyImport.ts # One-time import of jobs from an older client.db
│ ├── Lifecycle.ts # The single gate between "running" and "working"
│ ├── LogStream.ts # Batched LOG_UPDATE frames (250 ms / 8 KB)
│ ├── RegistrationState.ts # serverUrl and registration secret as a registration changes them; agent mode
│ ├── SetupPin.ts # In-memory PIN guarding registration, local and outbound
│ └── Version.ts # Agent version, read from dist/VERSION
├── features/ # Business logic
│ ├── Executor.ts # Run orchestration and the concurrency queue
│ ├── Handlers.ts # WebSocket message routing
│ ├── Scheduler.ts # Minute loop that starts jobs whose next run is due
│ ├── SchedulePlan.ts # What that loop decides about one job, as pure functions
│ ├── TunnelClient.ts # Requests a tunnel lease and rewrites PBS_REPOSITORY
│ └── execution/ # The steps of a single run
│ ├── CommandBuilder.ts
│ ├── ProcessRunner.ts
│ ├── RunPreparation.ts
│ └── SnapshotQuery.ts # Reads back the snapshot a backup created
├── repositories/ # Data access layer
│ ├── JobRepository.ts
│ ├── JobHistoryRepository.ts
│ └── JobScheduleStateRepository.ts
├── web/
│ ├── server.ts # Local Fastify web server (status & registration)
│ └── public/ # Status and registration pages
└── index.ts # Application entry point
Logging is not in this tree: logger comes from @pbcm/shared/node, the same instance the
server uses, so LOG_LEVEL and LOG_FORMAT behave identically on both sides.
🏗 Core Components¶
0. Lifecycle Gate (src/core/Lifecycle.ts)¶
startAgentActivity() is the single gate between "the process is running" and "the agent is
working". Scheduler, the recovery of interrupted runs and the outgoing server
connection all start there, and only for a registered agent.
Everything the agent does happens under its identity — a backup is filed in PBS under
--backup-id, a status update names a client the server has to recognise, a history row is
synced to that client. An unregistered agent has no identity to do any of it under, so it
starts none of it: it serves its Web UI and waits. The gate is one function rather than a
check at each starting point because two paths lead to it — a registered agent starting up,
and a registration completing while the process runs. The second is why the call cannot live
in index.ts alone: an agent registered through its Web UI has to start working without a
restart. Executor.executeBackup and executeRestore check the same condition again as a
backstop.
1. Core Connection (src/core/Connection.ts)¶
The Connection class manages the persistent WebSocket connection to the central server.
- Features: Automatic reconnection, ping/pong health checks, and secure transmission of all payload data.
- Reconnect: The delay steps through
5s → 10s → 30s → 60sand then stays there, with up to 3s of jitter added each time. The same ladder asClientConnector.RECONNECT_DELAYSon the server, which dials outbound agents — the two directions of one link should not behave differently. Before this it was a flat 5s with no jitter, so a fleet of agents and one server restart meant all of them knocking on the same beat. The counter resets onAUTH_SUCCESS, not when the socket opens: a connection that dies before the handshake is not a working one. All reconnects go through a single timer, and a connect that times out closes its socket — leaving it open used to let a laterconnect()close it, whose close handler then scheduled a second reconnect alongside the attempt already running. - Registration Flow: If the client is unregistered, the user must provide a temporary registration
token. The client POSTs this to the server and receives its identity in return —clientIdand a permanent auth token, both issued by the server — which it saves together toidentity.jsonin its data directory. The agent never picks an id for itself: the same value is the PBS--backup-id, so the side that decides which client a snapshot belongs to is the side that keeps the client list. - Identity on connect: Every session presents both halves (
/ws/agent?clientId=…&token=…) and the server checks that they name the same client. An agent that already holds an identity refuses to register a second time — registering again would issue a new id and leave the old row, jobs and history included, behind on the server. To move a client to a fresh identity, deleteidentity.jsonfrom its data directory first.
2. Job Scheduler (src/features/Scheduler.ts)¶
The Scheduler is responsible for evaluating and triggering scheduled backup jobs locally, independent of server connectivity.
- It reads job configurations from the agent's own
jobs.json. - Once a minute it checks every job with an active schedule against its stored next run time.
- Upon reaching the scheduled time, it triggers the
Executorautonomously. - Days and weeks are stepped by calendar date in the agent's time zone (
TZ), not by 24-hour blocks, so a job keeps its time of day across daylight saving changes. The time of day comes from the start entered in the dashboard, whichschedule.jsonkeeps asanchor: a run that had to move because its time did not exist that night does not shift every run after it. Hours, minutes and seconds stay fixed intervals. Weekdays are checked in the same zone. - After execution, it records the last and next run times in
schedule.json, and emits aJOB_NEXT_RUN_UPDATEevent to the server (if connected). - A run that starts late is reported as missed. When the scheduler finds a run more than
five minutes past its time (
MISSED_AFTER_MSinshared/src/schedule.ts) -- the agent was stopped, the machine asleep -- it first writes a history entry with the statusmissedand then starts the one catch-up run as before. The entry says when the run was due, how late it started and how many scheduled runs the catch-up stands for:Scheduled for 2026-10-03T02:00:00.000Z, started 7 h 12 min late. 3 scheduled runs were due; they are caught up by one.It is a run like any other: kept underhistory/until the server has acknowledged it, reported as the webhook eventjob.missed, and listed on the dashboard until a user marks it as seen. Up to a minute of delay is the scheduler's own; the rest of the five lets an agent be restarted or updated across a scheduled time without a warning. - A time that had already passed when the schedule was saved is not a missed one. A start
entered in the past means "run at once", and a schedule switched back on finds the time it
was switched off at.
schedule.jsonkeeps when the schedule was last saved asenteredAtfor this; the rule isisMissedRuninshared, tested there.
3. Job Executor (src/features/Executor.ts)¶
Executor keeps the four entry points its callers use, the concurrency queue, and the orchestration. The steps of a run live under features/execution/:
| Module | Responsibility |
|---|---|
RunPreparation.ts |
Everything both kinds of run need before the spawn: the temporary keyfile, the repository environment (PBS_REPOSITORY, PBS_PASSWORD_FD, PBS_FINGERPRINT), and the fingerprint resolution. Backup and restore each kept their own copy of this — the arrangement in which the keyfile cleanup already went missing once. The keyfile is created exclusively (0600), and keyfiles a killed process left in the temp directory are removed at startup. |
CommandBuilder.ts |
buildBackupArgs / buildRestoreArgs / buildSnapshotListArgs. Pure functions with no I/O, and therefore the first part of the agent that can be checked without a running process. A job's excludes become one --exclude each, applied by the CLI to every archive of the run. backupParams must not contain --backup-time — the agent sets it itself (below) — and a run with it is refused. |
ProcessRunner.ts |
runProxmoxClient, runScript, finishFailedRun — everything that starts a child process and reports what became of it. Takes an onSlotRelease callback rather than knowing about the queue. |
SnapshotQuery.ts |
querySnapshot: proxmox-backup-client snapshot list host/<clientId> --output-format json, with the environment of the backup it follows. Never throws; every failure ends in an error text. |
The Executor acts as a wrapper around the actual proxmox-backup-client CLI binaries.
- It translates abstract JSON job configurations into CLI arguments for
proxmox-backup-client backuporproxmox-backup-client restore. - It spawns a child process and captures real-time
stdout/stderrstreams, forwarding them asLOG_UPDATEevents over the WebSocket. - Bounded output (
core/CappedLog.ts): each channel keeps at mostlogCapBytes(default 256 KB), holding the head and the tail with an explicit marker where the middle was dropped. Not a ring buffer: the invocation and the first errors are at the top and the reason a run failed is at the bottom, and a ring buffer keeps only the second half. The cap matters because the captured output is paid for three times — held in memory for the whole run, written to the run's history file, and synced to the server from there. - Batched log frames (
core/LogStream.ts):LOG_UPDATEevents are collected and sent every 250 ms or once 8 KB accumulate, rather than one frame per chunk from the pipe. Safe because these frames are display-only; what must not slip is their order against the run's finalSTATUS_UPDATE, so the stream is flushed before that and in the spawn-error path. - The snapshot of a backup:
backuphas no JSON output — it reports only text on stderr — so a run learns what it created in two steps: - The agent names the snapshot. Right before the spawn, after the queue and the tunnel lease, it appends
--backup-time <epoch>and recordshost/<clientId>/<time>in the run's history file. Not earlier: the PBS refuses a time that is not after the newest snapshot of the group. The same principle as--backup-id: the side that holds the identity sets it. - It reads the snapshot back once the CLI has exited with 0:
snapshot listwith--output-format jsonasks the same PBS endpoint the server's snapshot view does. The run staysrunningmeanwhile and says so withphase: "snapshot"in aSTATUS_UPDATE; the tunnel lease is released only afterwards, since a tunnel job reaches the PBS for the query the same way. A snapshot counts only when it has its manifest (index.json.blob), which the CLI uploads last. The size, the files with their crypt mode, the key fingerprint and the owner are stored with the run assnapshotDetailsand sent with the finalSTATUS_UPDATE(phase: null) and the history sync. If the query fails — timeout after 30 s, PBS unreachable, snapshot missing — the run stays asuccess, and the reason is stored assnapshotError. Restores and failed backups skip the query. The PBS's verify state is not read: a verify job sets it hours later. - Runs interrupted by a restart (
Executor.cleanupRunningJobs): on startup, before the server is connected, every run stillrunningis settled. A backup that recorded its snapshot is looked up on the PBS first — it may well have finished, if the process ended during the query or before the status was written. Found and finished, it becomessuccesswith its details and a note in stderr; not finished,abort; not answerable (job deleted, PBS unreachable),abortwith the reason assnapshotError. A tunnel backup cannot be looked up then, since the lease needs the server: it staysrunningand is checked once the server has authenticated the agent (Connection.onAuthenticated). Never a provisionalabort— that would be a second final status, and a second webhook. Every other run stillrunningbecomesabort, as before. - Runs aborted on request (
Executor.abortRun,ProcessRunner.abort): the server'sABORT_RUNnames a run.ProcessRunnerkeeps every run it has accepted in a map until its CLI has exited, and an abort is a flag on that entry plus a signal —SIGTERM, andSIGKILLten seconds later for a process stuck in a read on a dead link. The run is not ended by the abort: it ends in the sameclosehandler as every other run, so the keyfile, the tunnel lease and the job's slot are given back by the same code, and the post-script runs as after a failure.runEndStatus(code, abortRequested)picks the status —abort, unless the CLI still exited with 0, in which case the work is done and the run a success. - Before the CLI exists — during the pre-script, or while the tunnel is opened — the flag is kept and read before the spawn; the run ends as
abortwithout starting. - Queued behind another run of its job, the run is the
Executor's: it is taken out of the queue and settled there. - Reading back its snapshot, a run is no longer in the map and the request is refused: the backup is in the repository.
- Not reachable is a run taken out of the queue and waiting out
queueDelaySeconds; it is answered as "not under way" and can be aborted once it has started. - History Synchronization (
features/HistorySync.ts): Every run is a file in the agent'shistory/directory, and every change to it is sent to the server viaSYNC_HISTORY— within a second, and in batches of 50 runs. Delivery is at-least-once: each run carries arevisionthatJobHistoryRepositoryraises whenever a field the server stores changes, and it stays due until the server has acknowledged that revision withHISTORY_ACK. A batch without an ack is offered again after a minute, and everything still due goes out on every reconnect. This replaced a watermark (lastSyncTime, which the server still sends for agents of an older build) that compared the server's clock with the agent's: a run the server failed to store, one written in the same second, or one from an agent whose clock lagged fell below it and was never sent again. The agent no longer syncs with a server that does not acknowledge; server and agent are released together. - Certificate pinning:
PBS_FINGERPRINTis taken from the job's repository copy, which ages — nothing updates it when the PBS renews its certificate. Before a direct run the Executor therefore measures the certificate itself (probeCertificatefrom@pbcm/shared/node, called infeatures/execution/RunPreparation.ts— the same function the server uses) and adopts the measured value only if regular CA validation against the real hostname succeeded; that check is the independent evidence that makes adoption safe. Against a self-signed PBS no such evidence exists, so the stored value stands and a genuine mismatch is left to fail the run — which is the entire purpose of a pin. An adopted value is written back to the job config so the next offline run has it, and reported to the server viaFINGERPRINT_OBSERVED(informational; the server does not adopt it). Tunneled runs skip all of this: they reach the PBS as127.0.0.1, where CA validation can never succeed, so the server measures and delivers the fingerprint with the tunnel lease instead (seedocs/tunnel.md). Which of the two paths a run takes follows from the job's owntunnel.required.
4. Local Web Server (src/web/server.ts)¶
The client includes a micro-server (Fastify) for local management and initial setup.
Which routes it serves is settled at startup from config.yaml (getWebRoutes()):
| Routes | Served when |
|---|---|
/status, /api/status/connection, /api/status/config, POST /api/connect |
enableStatusPage (default true) |
/register, POST /api/register |
enableRegisterPage (default true); answer 404 once the agent is registered |
/, /api/status/server, /api/status/auth, the static files |
either page is enabled |
/ws/register, /ws/agent |
outbound mode: no serverUrl |
/api/health |
the agent runs in its container image (PBCM_CONTAINER=true); answers loopback only |
A serverUrl means inbound mode, which needs no route here — the agent dials the server.
Without one, an unregistered agent waits on /ws/register for the server to register it
with the setup PIN (or PBCM_REGISTRATION_SECRET), and a registered one needs /ws/agent.
/ws/register refuses every caller once the agent has an identity. Since the routes are
fixed at startup while the mode is not, /ws/agent also refuses a connection once the agent
has been registered inbound through its register page.
The register page closes the same way, per request rather than at startup: once the agent
has an identity, /register redirects to /status (or answers 404 without a status page),
register.html is no longer served and POST /api/register answers 404. There is nothing
left to do there — registering a second time would leave the client's old row behind on the
server — and the setup PIN that guarded it is gone with the registration. The status page
then shows how to start over instead of a link to the register page.
The static handler leaves out the HTML file of a disabled or closed page, so /status.html is not a
way around enableStatusPage: false. With neither page nor outbound mode the server binds
127.0.0.1 for the health route alone; outside the container it then does not start at
all, and says so in the log. This replaces DISABLE_WEB_UI, which switched off the
outbound routes and the health route along with the pages.
None of the page endpoints asks for a login, so none of them takes a target from the
caller. /api/status/server checks the configured server and no other — it used to accept
any address as ?url=, which let anyone who could reach the port have the agent probe its
own network. The register page needed that for one pre-check; POST /api/register, behind
the setup PIN, now reports it itself: every error carries stage — input (form or PIN,
nothing was sent), server (nothing answered at the URL) or register (a server answered
and refused). POST /api/connect dials the configured server as well; while a handshake is
under way, further calls wait for that one instead of starting over, so calling it in a loop
cannot keep a disconnected agent offline.
Plain HTTP unless config.yaml carries a tls block:
Relative paths resolve against the agent's directory. Both files are read and checked at startup, and a tls block that cannot be read ends the start rather than falling back to HTTP — an agent configured for TLS that quietly served plaintext would hand its auth token out on /ws/register while looking perfectly healthy. The log line after listen() names the scheme actually in use, as does the setup PIN block. The container's health check follows the scheme without being told (see Health check).
This matters in outbound mode, where the server dials /ws/agent with the auth token in the query string. With TLS on, the client's target address on the server has to say so: wss://host:port. The two are set separately and have to agree. A reverse proxy terminating TLS in front of the agent works just as well — leave tls unset and point the proxy at the plain port. What the agent stores is the path you wrote, so saving its configuration does not rewrite a relative path into an absolute one.
It is unrelated to the SSH reverse tunnel, which carries backup traffic to the PBS rather than the agent session — see Outbound mode and the tunnel.
- Status Page: Provides a quick overview of the client's connectivity and scheduling state.
- Registration: Allows manual registration via the web interface by entering the registration token issued for the client in the dashboard. The PBCM server's certificate is verified for the registration request and for the WebSocket connection; for a server with a self-signed certificate set
allowSelfSignedCertificates: true, which then applies to both. Only the reachability check tolerates any certificate, since it sends nothing and trusts nothing it receives. The decision is made per request (core/ServerHttp.ts, the WebSocket options) — previously this was a process-wideNODE_TLS_REJECT_UNAUTHORIZED=0that stayed switched off for the lifetime of the agent and would have defeated the certificate probe above, and later a tolerant registration followed by a strict WebSocket, so a self-signed server registered but never connected. - Setup PIN (
core/SetupPin.ts):POST /api/registerand/ws/registerrequire a PIN that the agent prints to its log on startup while it has no identity — when the register page is enabled, or when it has noserverUrland noPBCM_REGISTRATION_SECRET(docker logs,journalctl -u pbcm-client). Without it, anyone who can route tolistenPortcould point an unregistered agent at a server of their choosing — the caller supplies both the server URL and the token.allowedNetworkscannot serve as that check (see below), so the guard is a shared secret instead of an address; whoever can read the machine's log already has the access that registering would grant.
The PIN lives in memory only. It is regenerated on every start, dropped once an identity
exists, and rotated after five failed attempts — which ends online guessing without
locking the operator out, since they read the new value from the same place. It is
checked before the isRegistered() gate, so the status code does not reveal whether
the agent already has an identity.
On /ws/register the server presents the same PIN, entered by the operator in the
dashboard's outbound wizard. For an unattended rollout, PBCM_REGISTRATION_SECRET — or
PBCM_REGISTRATION_SECRET_FILE naming a file that holds it — sets a secret that is accepted
there instead; it is read from the environment only, and setting both variables, or a file
that cannot be read or is empty, stops the agent. Once used it is dropped from memory and the
log asks for it to be removed from the environment. A registrationSecret left in
config.yaml by an earlier version is ignored with a warning. allowedNetworks still
restricts who may dial /ws/register at all.
- Port: listenPort in config.yaml (default 3001), overridden by the environment
variable PBCM_CLIENT_PORT. In outbound mode the same server also serves /ws/register
and /ws/agent, so a changed port must match the client's target address on the server —
editable in the client editor.
- Allowed networks: allowedNetworks in config.yaml — a list of CIDR networks the
server may dial /ws/register and /ws/agent from. Empty (the default) allows every
address, which is what the agent did before the setting existed. It matters most for
/ws/register: there the caller supplies the auth token the agent then stores, and the
listener binds every interface the host has. The local Web UI on the same port is
deliberately not restricted — it is the surface an operator registers the agent
through, and a list holding only the server's address would shut them out of it; that surface is guarded by the setup PIN above instead. The address checked is the
socket's peer (no trustProxy), so an agent behind a
reverse proxy must allow the proxy's address, not the server's. A wrong value is only
repairable locally on the client host: the connection one would fix it over is the one
being refused.
Health check¶
The client images declare a HEALTHCHECK against /api/health. It reports on the agent —
whether its data directory is writable — not on the connection to the server: an agent that
cannot reach the server still runs its jobs.
The probe cannot read config.yaml, so the agent tells it where to ask: after listen() it
writes the address it actually serves to /tmp/pbcm-health.json
({"url":"https://127.0.0.1:4001/api/health"}). A port or tls block set only in
config.yaml is therefore followed just like PBCM_CLIENT_PORT, and an edited
config.yaml does not move the probe before the agent has restarted onto it. The file lives
under /tmp rather than in the data volume, so an address from an earlier container does not
outlive it, and it is removed before each listen(), so a failed start leaves none behind.
Without the file — before the first listen(), or in an image from before it existed — the
probe asks PBCM_CLIENT_PORT (default 3001), plain HTTP first and HTTPS only when the
connection fails. The certificate is not verified in either case: the request goes to
127.0.0.1 inside the container, where no name in it would match, and sends nothing that
needs protecting.
5. Event Handlers (src/features/Handlers.ts)¶
Incoming WebSocket messages from the server (e.g., manual trigger requests from the dashboard) are routed to these handlers:
JOB_LIST_CONFIG→ returns all local job configsJOB_SAVE_CONFIG→ persists a job config update locally and reschedulesJOB_DELETE_CONFIG→ removes a job and cancels its scheduleABORT_RUN→ asks theExecutorto end a run that is under way, and answers whether it couldGENERATE_KEY_CONFIG→ generates a new encryption keyFS_LIST→ lists directories/files on the local file systemGET_VERSION→ returns the agent versionHISTORY→ returns local job history
Three message types are dispatched in core/Connection.ts rather than here, because they
do not answer a request: RUN_BACKUP and RUN_RESTORE go straight to the Executor, and
TUNNEL_ACQUIRE_RESULT resolves the lease the TunnelClient is waiting on. The validation
table INBOUND_SCHEMAS in that file covers all of them, handled here or not.
🗄 Data Files¶
The agent keeps its state as JSON files in its data directory: client/data, or the path in
PBCM_CLIENT_DATA_DIR. In the container that is the client-data volume. This is what lets
the agent run its scheduled backups with no server in reach.
| File | Contents |
|---|---|
identity.json |
clientId and authToken, issued by the server at registration. Without it the agent is unregistered. An older agent's pair is moved here out of config.yaml on the first start. |
jobs.json |
The job configurations. The only copy there is: the server lists, saves and deletes jobs through the agent and keeps none of them. |
schedule.json |
Last and next run time per job, plus the entered start (anchor) the time of day is taken from and when the schedule was last saved (enteredAt). Written on every scheduled run, so it is kept apart from jobs.json. |
history/<run>.json |
One file per run: status, timing, exit code, output, the snapshot of a backup (snapshot, snapshotDetails, snapshotError), and the sync revisions. |
- Atomic writes: every file is written to a temporary file, synced, and renamed over the old one, so a power cut leaves either the old or the new version, never half of one.
- Readable by the agent only: the directory is
0700(set again at every start) and every file0600.jobs.jsonholds the PBS encryption keys and the repository secrets in plain text. - Damaged files are set aside, not overwritten: a
jobs.jsonthat does not parse is renamed tojobs.json.corrupt-<timestamp>and reported in the log; entries that do not parse are dropped and the original file is kept the same way. Restore from there by hand. Anidentity.jsonthat does not parse, or is not the pair, is set aside likewise; the agent then starts unregistered. - Retention: a run stays until the server has acknowledged it. Of the acknowledged
ones, the newest 50 are kept — as many as the agent's own
HISTORYanswer returns — and the rest are deleted after each acknowledgement. Queued and running runs are always kept. An agent cut off from its server keeps what it has not delivered until it is back — up to 500 runs. Past that, each new run drops the oldest undelivered one, with a warning in the log (History full, dropped the oldest unacknowledged runs); those never reach the server. A run holds at most twicelogCapBytesof output, so the limit bounds the history at about 256 MB with the defaults. At one run an hour that is about three weeks of outage, at one a day well over a year. - Import from SQLite: an agent that still has the
client.dbof an older version imports its jobs and their schedule state on the first start and renames the database toclient.db.migrated. The history is not imported; runs the server had not received by then do not reach it (the log says how many). The import usesnode:sqliteand needs Node 22.13 or later; if it fails, the agent does not start, so that no job is lost.
Outbound mode and the tunnel¶
Two separate things, and the agent treats them as such.
Without a serverUrl, the agent runs in outbound mode: it does not dial out itself but serves /ws/register and /ws/agent
instead. That is the whole of it — it says nothing about how the PBS is reached.
Whether a run goes through the tunnel is each job's own setting: tunnel.required arrives
with the job config, is stored with it, and is read back before the run. When it is set, the
agent requests a lease through TunnelClient and replaces host and port in PBS_REPOSITORY
with the loopback endpoint. Living in the job config is what makes a scheduled run offline
take the route the operator chose — and it is the only copy of the setting, so nothing can
fall out of step with it.
A restore carries the same tunnel.required, but in the RUN_RESTORE payload rather than
in a stored config: it is triggered from the dashboard, is never scheduled, and the operator
answers the question in the restore form. Absent means a direct connection.
Details: tunnel.md.
Neither of the two protects the agent session itself. The tunnel is asked for per run and
released afterwards; the WebSocket the server dials stands beside it and is plaintext unless
the agent serves TLS. That is what the tls block above is for.