Operations Runbook
This runbook covers the operator-facing surfaces of a live KinTunnel deployment. It assumes the engine and admin containers are running from the stock Compose stack. For install and initial bring-up see Quick Start and Docker Compose Installation.
Environment Variables
All variables use the KINTUNNEL_* namespace. The *_FILE suffix form reads from a file path (secrets bind-mounted under /etc/kintunnel/secrets).
Engine
| Variable | Type | Default | Semantics |
|---|---|---|---|
KINTUNNEL_ENV |
enum (development | production) |
production |
Controls log verbosity and error verbosity in responses. |
KINTUNNEL_DRY_RUN |
bool | true |
When true, validate state and render configs without touching host networking. Flip to false after the host passes the deep health report. |
KINTUNNEL_ENABLE_HOST_NETWORKING |
bool | false |
Hard gate for wg / ip link / iptables exec. Without this, the apply path is a no-op even in non-dry-run. |
KINTUNNEL_NAT_APPLY |
bool | false |
When true, networking.apply() runs iptables / sysctl. Off by default so a half-configured Compose boot stays non-destructive. |
KINTUNNEL_FORWARDING_REQUIRED |
bool | true |
When true, health.tun and health.forwarding are required; when false, they warn only. |
KINTUNNEL_WG_EGRESS_INTERFACE |
string | unset (auto) | Override the egress interface for MASQUERADE. If unset, the engine resolves from the default route. |
KINTUNNEL_DATA_DIR |
path | /var/lib/kintunnel |
Where state.json lives. |
KINTUNNEL_BACKUP_DIR |
path | /backups |
Snapshot directory. Must be on the same filesystem as KINTUNNEL_DATA_DIR for atomic rename. |
KINTUNNEL_BACKUP_RETENTION_COUNT |
int (1-1000) | 10 |
Number of snapshots kept by the retention pruner. |
KINTUNNEL_BACKUP_LOCK_TIMEOUT_MS |
int (1000-300000) | 30000 |
Max wait acquiring the exclusive lock on /backups/.lock. |
KINTUNNEL_APPLY_BOOTSTRAP_TIMEOUT_MS |
int (1000-120000) | 15000 |
Max wait for the ip link add → wg setconf → ip addr → ip link up cold-start sequence. |
KINTUNNEL_LOG_LEVEL |
enum (debug | info | warn | error) |
info |
NDJSON log threshold. Set debug for verbose apply path tracing. |
KINTUNNEL_AUDIT_LOG_ROTATION_BYTES |
int | 5242880 |
Persistent audit log rotates when it exceeds this size. |
KINTUNNEL_AUDIT_LOG_RETENTION_COUNT |
int (1-100) | 10 |
Number of rotated audit files kept. |
KINTUNNEL_ENGINE_PORT |
port | 9090 |
Internal HTTP API port. Do not publish outside the Compose network. |
KINTUNNEL_PUBLIC_ENDPOINT |
host:port | unset | Public address advertised to peers in their configs. |
Secrets (always via _FILE form)
| Variable | Form | Required | Notes |
|---|---|---|---|
KINTUNNEL_ADMIN_TOKEN |
string or _FILE |
yes | Admin UI bearer token. Min 32 chars in production; see assertStrongEngineApiToken for the engine-side check. |
KINTUNNEL_ENGINE_API_TOKEN |
string or _FILE |
yes | Engine API bearer token. Same strength check. |
First-Run Checklist
- Generate the admin token. Use
openssl rand -base64 48(or longer) and write it toconfig/secrets/admin-token.txtwithchmod 0400. - Generate the engine API token. Distinct from the admin token. Same strength requirement. Write to
config/secrets/engine-api-token.txt. - Configure the public endpoint. Set
KINTUNNEL_PUBLIC_ENDPOINT=your.vps.example:51820in.env. This is the address embedded in generated peer configs. - Leave
KINTUNNEL_DRY_RUN=truefor the first boot. It validates state without touching the host. Compose up the stack and verify the engine's/healthreportsok=truewithstate_iopassing. - Verify
/v1/capabilities. ConfirmhasWg,hasWgQuick,hasTunare alltrue. Anyfalsehere means a missing capability on the host or in the image. - When ready for live mode: flip
KINTUNNEL_DRY_RUN=falseandKINTUNNEL_NAT_APPLY=truein the minimal-VPS overlay, thendocker compose up -d. Wait for the engine to reportok=trueandapply.interface.createdin the audit log. - Set
KINTUNNEL_LOG_LEVEL=infofor production. Usedebugonly when troubleshooting apply path issues — it generates significant log volume.
Backup Runbook
The engine exposes seven backup endpoints on the /v1/backups family. All require Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN.
Create a snapshot
curl -X POST -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups \
-H "Content-Type: application/json" \
-d '{"trigger":"manual"}'
Response (201):
{
"snapshot_id": "0190a3b4-7c2e-7def-8a1b-2c4d5e6f7890",
"created_at": "2026-06-29T12:00:00.000Z",
"engine_revision": 42,
"trigger": "manual",
"size_bytes": 8192,
"file_count": 2,
"corrupt": false
}
List snapshots
curl -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups
Pass ?corrupt_only=true to surface snapshots whose manifest failed SHA-256 verification. The engine does not delete corrupt snapshots automatically — operators decide.
Read a manifest
curl -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups/snap-<id>
Returns both the BackupSummary and the full BackupManifest, including per-file SHA-256 hashes.
Dry-run a restore (restore plan)
curl -X POST -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups/restore-plan \
-H "Content-Type: application/json" \
-d '{"snapshot_id":"snap-<id>"}'
Response carries peer_changes.added, peer_changes.removed, peer_changes.modified, plus affected_public_keys and any warnings[]. Use this to preview impact before applying.
Apply a restore
curl -X POST -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups/snap-<id>/restore \
-H "Content-Type: application/json" \
-d '{"apply": true}'
The engine takes a safety snapshot first (trigger: "pre-rotate") and returns its snapshot_id in the response. After the swap, operators MUST call POST /v1/reconcile to push the restored state onto the WireGuard interface — see Restore Disaster Scenarios.
Pass "force": true to skip the safety snapshot when you are certain you want to overwrite without rollback.
Export a snapshot
curl -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
-o snap-<id>.json \
http://localhost:9090/v1/backups/snap-<id>/export
Returns a JSON wrapper carrying the manifest, base64-encoded state.json, and the SHA-256 of the state. Suitable for off-host archival or migration to a new VPS.
Delete a snapshot
curl -X DELETE -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups/snap-<id>
Refuses to delete the most-recent snapshot to prevent accidental loss. If you really need to delete it, delete an older snapshot first so the target is no longer the newest.
Restore Disaster Scenarios
Lost peer data
A peer was created and its private key was lost from the admin UI's recent activity. The server still has the peer config and the public key, so the peer can technically still connect — but without its private key the device cannot reconnect after a fresh WireGuard client install.
Recovery: restore the most-recent snapshot from before the peer was created. This regenerates the peer's private key on the server side and re-renders the config.
# 1. List snapshots, find the most recent one before the lost peer
curl -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups
# 2. Restore it (safety snapshot is automatic)
curl -X POST -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/backups/snap-<id>/restore \
-H "Content-Type: application/json" \
-d '{"apply": true}'
# 3. Push the restored state to WireGuard
curl -X POST -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/reconcile
Bad peer creation
A peer was created with a typo, the wrong AllowedIPs, or against the wrong person. The peer config is broken and the device on the other end cannot connect.
Recovery: restore the snapshot from immediately before the bad creation. If you took a snapshot just before (trigger: "manual"), use it. Otherwise restore the previous nightly / scheduled snapshot and accept that other changes since then are also rolled back.
Engine restart after restore
The restore endpoint swaps state.json on disk but does not automatically reconcile onto the WireGuard interface. After a successful apply: true restore, the operator MUST call POST /v1/reconcile:
curl -X POST -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
http://localhost:9090/v1/reconcile
Without this step, the live wg0 interface keeps its old peer set while the engine's state.json reflects the snapshot. The /health endpoint will surface this divergence as a drift.detected event in the audit log and as ok=false if health.interface runs against the live interface.
Health Monitoring
The engine exposes three health endpoints with escalating depth:
/health— unauthenticated, container-orchestrator-friendly. Returns the sameHealthReportshape as/v1/health./v1/health— bearer-token-gated equivalent of/health. Same shape./v1/capabilities— informational inventory (hasWg,hasWgQuick,hasTun,hasIptables,ipForward, …). Never returns a non-200 status; it is a static capability dump.
When /health returns 503
A 503 means at least one required check failed. Inspect checks[] to find which one:
{
"ok": false,
"checks": [
{"name": "tun", "status": "fail", "detail": "/dev/net/tun not readable", "required": true},
{"name": "state_io", "status": "pass", "detail": "ok", "required": true}
],
"required_failing": ["tun"],
"warnings": []
}
| Failing check | Likely cause | Fix |
|---|---|---|
tun |
/dev/net/tun not exposed to the engine container |
Add devices: [/dev/net/tun:/dev/net/tun] to the engine service in docker-compose.yml and restart. |
forwarding |
net.ipv4.ip_forward=0 and KINTUNNEL_FORWARDING_REQUIRED=true |
Either flip the sysctl on the host (sysctl -w net.ipv4.ip_forward=1) or set KINTUNNEL_FORWARDING_REQUIRED=false. |
interface |
WireGuard interface is missing or down | Call POST /v1/reconcile. If it persists, check /v1/capabilities — hasWg may be false. |
nat |
MASQUERADE rule absent and KINTUNNEL_NAT_APPLY=true |
Inspect iptables -t nat -S POSTROUTING on the host. Either re-run reconcile or set KINTUNNEL_NAT_APPLY=false if you do not need NAT. |
iptables |
iptables binary not invokable from the engine container |
Confirm cap_add NET_ADMIN is present and the engine image has iptables installed. |
state_io |
state.json unreadable / unwritable |
Check the kintunnel-data named volume is mounted and disk is not full. |
The port check is warn-only by design — UDP reachability is best-effort and does not flip the engine to ok=false.
Metrics Scraping
The engine exposes Prometheus text exposition at /metrics. No prom-client dependency; the engine emits a minimal text serializer itself.
Example Prometheus scrape config:
scrape_configs:
- job_name: kintunnel-engine
scheme: http
static_configs:
- targets: ['kintunnel-engine:9090']
metrics_path: /metrics
scrape_interval: 30s
bearer_token_file: /etc/prometheus/secrets/kintunnel-engine-api-token
Note: /metrics is not currently bearer-token-gated in v1 because Prometheus needs to scrape it without credential rotation; if you scrape over a public network, put a reverse proxy with mTLS in front.
Metrics emitted
| Kind | Series |
|---|---|
| Counter | kintunnel_peers_total, kintunnel_peers_active, kintunnel_peers_revoked, kintunnel_peers_deleted, kintunnel_reconcile_runs_total, kintunnel_reconcile_failures_total, kintunnel_apply_failures_total, kintunnel_backup_creates_total, kintunnel_backup_restores_total |
| Gauge | kintunnel_state_revision, kintunnel_last_reconcile_timestamp_seconds, kintunnel_last_apply_duration_seconds, kintunnel_peers_active |
| Histogram | kintunnel_reconcile_duration_seconds, kintunnel_apply_duration_seconds |
Audit Log Query
The persistent audit log is queryable via GET /v1/audit:
# All apply path events
curl -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
"http://localhost:9090/v1/audit?action=apply.peer.removed"
# Backup lifecycle events from a specific actor
curl -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
"http://localhost:9090/v1/audit?action=backup.&actor=engine"
# Everything since a timestamp
curl -H "Authorization: Bearer $KINTUNNEL_ENGINE_API_TOKEN" \
"http://localhost:9090/v1/audit?since=2026-06-29T00:00:00Z"
Filters combine with AND. Use action=prefix. (with the trailing dot) to fetch all actions in a namespace (e.g. apply., backup., networking.).
Known gap
Audit events emitted directly from state.ts (peer lifecycle: peer.created, peer.revoked, peer.deleted) currently land in the ring buffer (state.events) and in /v1/status recent activity, but they do not all flow into the persistent NDJSON sink. The reconcile path is fully covered. Operators relying on durable peer lifecycle history should treat the ring buffer as the source of truth until Wave 4 closes this seam — see architecture.md.
Log Level Tuning
KINTUNNEL_LOG_LEVEL filters NDJSON output to stdout:
environment:
KINTUNNEL_LOG_LEVEL: info # default in production
debug— verbose apply path tracing (planApply,executeApplystep boundaries, per-peerwg setinvocations). Heavy volume; use only during incident triage.info— startup, lifecycle events, reconcile start/stop, backup create/restore. The default.warn— degraded operation (port probe failed, idempotent rule insertion skipped, partial rollback).error— failed applies, lock timeouts, restore failures. Always emitted.
Combine with the service and event fields for filtering:
# Follow only reconcile events
docker logs -f kintunnel-engine | jq -c 'select(.event | startswith("reconcile"))'
# Follow apply failures only
docker logs -f kintunnel-engine | jq -c 'select(.event == "apply.failed")'
Container Hardening
The locked architectural decision (2026-06-29) is Linux capabilities only — no root, no docker.sock, no host PID namespace. The engine service in docker-compose.yml reflects this:
services:
engine:
init: true
read_only: true
cap_drop: [ALL]
cap_add: [NET_ADMIN, NET_RAW]
security_opt: [no-new-privileges:true]
tmpfs: [/tmp, /run]
volumes:
- kintunnel-data:/var/lib/kintunnel
- kintunnel-backups:/backups
Why NET_ADMIN and NET_RAW
- NET_ADMIN — required for
wg,wg setconf,ip link,ip addr,ip route, andiptables. - NET_RAW — required to open
/dev/net/tunfor the WireGuard interface. Without it,ip link add <name> type wireguardfails withEPERM.
cap_drop: ALL plus a minimal cap_add is the locked model. Do not promote the engine to a broader capability set "for convenience."
Why not root
Root would be enough for everything the engine does, but a root compromise of the engine process would let the attacker escape into the host kernel. Running as a non-root UID inside a capability-bounded container means the engine process itself is unprivileged; it only exercises its bounded capabilities on the operations it explicitly performs.
Why not docker.sock
Mounting /var/run/docker.sock would let the engine spawn or inspect arbitrary containers — far beyond its mandate. WireGuard + iptables is all it needs; mount nothing else from the host.
Why not host PID namespace
pid: host would let the engine see and signal host processes. Combined with NET_ADMIN it would be enough to attack other services on the host. Keep the engine inside its own PID namespace.
Why a separate backups volume
kintunnel-backups is a named volume, distinct from kintunnel-data. This separation lets operators mount backups on a different physical disk, snapshot it independently, or rotate it without touching live state. The default ownership is 0:0 inside the container because the engine process runs as UID 0 (it needs to call iptables); read_only: true on the root filesystem means writes only land on kintunnel-data, kintunnel-backups, /tmp, and /run.