# Daemon `dropbear daemon` is a long-running supervisor that will respond to file system events and periodically poll the bucket for changes. It calls the same sync pipeline as the one-shot `dropbear sync` on a fixed interval, per root, in a single process. Background operation belongs in a daemon; everything else in this release builds on the process model this subcommand locks in. This page covers what the daemon does, the two invocation modes, the on-disk config file, the tick log envelope, and `jq` recipes for filtering the stream. ## What it does For each supervised root, the daemon runs an independent tick loop. Each tick: 1. Runs `upload.Run` against the root's state DB and bucket — the same pipeline `dropbear sync` invokes. 2. Emits a structured `tick_start` record on stdout before the tick begins. 3. Emits a `tick_end` record on stdout when the tick finishes (with the full `dropbear sync --json` envelope nested under a `sync` attribute), or a `tick_skip` record if the root went offline mid-flight. `SIGINT` and `SIGTERM` shut the daemon down cleanly. The first signal stops scheduling new ticks; the in-flight tick on each root is allowed to finish, then the process exits 0. A second signal during shutdown cancels the in-flight tick contexts and exits 130 within one second. ## Invocation modes ### Positional args (manual testing) ``` dropbear daemon --interval=1s /path/to/root dropbear daemon --interval=30s /path/to/rootA /path/to/rootB ``` Useful for kicking the tyres without committing a config file. With one or more positional `` arguments, the daemon supervises exactly those roots and ignores any `[[roots]]` block in `daemon.toml`. It still reads `log_format` and `default_interval` from the file if present, and looks up each positional root in the file to pick up its specific `interval`. ### Config-file driven (installation) With no positional args, the daemon reads its config file at: | Platform | Path | | -------- | ---------------------------------------------------------------------------------- | | Linux | `$XDG_CONFIG_HOME/dropbear/daemon.toml` (default `~/.config/dropbear/daemon.toml`) | | macOS | `~/Library/Application Support/dropbear/daemon.toml` | | Windows | `%AppData%\dropbear\daemon.toml` | The default-location file is optional but required when no positional args are supplied. Pass `-c, --config ` to read a config file from an explicit location instead. An explicit path is treated as required: a missing file produces exit 64 rather than falling back to defaults. ``` dropbear daemon -c /etc/dropbear/daemon.toml dropbear daemon --config ./local-daemon.toml /path/to/extra-root ``` ## `daemon.toml` shape ```ini log_format = "json" # "json" | "text" | "pretty"; default "json" log_level = "info" # "debug" | "info" | "warn" | "error"; default "info" default_interval = "5m" # Go duration; default "5m"; minimum "1s" watcher_debounce = "500ms" # Go duration; default "500ms"; minimum "50ms" head_poll_interval = "10s" # Go duration; default "10s"; minimum "1s" head_poll_max_interval = "120s" # Go duration; default "120s"; must be ≥ head_poll_interval status_api = true # bool; default true. Set false to disable the local status HTTP listener. status_api_addr = "127.0.0.1:0" # string; default loopback IPv4 ephemeral. Host MUST be 127.0.0.1 or ::1. status_api_require_auth = true # bool; default true. Set false ONLY when an external proxy handles auth. status_tick_history = 100 # int; default 100; range [10, 10000]. Per-root tick-history ring capacity. [[roots]] path = "/home/me/work" interval = "30s" # optional per-root override; falls back to default_interval [[roots]] path = "/home/me/archive" ``` Validation: - `log_format` must be `"json"` or `"text"`. - `default_interval` and any per-root `interval` must be ≥ `1s`. - `watcher_debounce` must be ≥ `50ms` (no "disable watcher" knob in this release). - `head_poll_interval` must be ≥ `1s`; `head_poll_max_interval` must be ≥ `head_poll_interval`. - `status_api_addr` must resolve to a loopback host (`127.0.0.1` or `::1`); non-loopback hosts are rejected at startup (exit 64). - `status_tick_history` must be in `[10, 10000]`. - Each `[[roots]].path` is required and must be unique. Relative paths are resolved against the directory containing `daemon.toml`. - Unknown top-level keys and unknown keys inside `[[roots]]` are rejected at startup (exit 64). Precedence for the effective per-root interval: `--interval` CLI flag > per-root `interval` > `default_interval` > built-in default `5m`. ## Exit codes | Code | Meaning | | ---- | -------------------------------------------------------------- | | 0 | Normal shutdown | | 64 | Config / usage error (no roots, bad TOML, unknown key) | | 66 | A specified root path is missing or its identity does not load | | 73 | Could not open the state database for a root | | 130 | Second SIGINT/SIGTERM forced in-flight tick cancellation | Startup is all-or-nothing: if any selected root fails to open, the daemon exits without starting any per-root loop and emits no `tick_start` records. ## Tick log envelope The daemon emits one JSON object per line on stdout. Every record carries: | Field | Type | Notes | | ---------------- | ------ | --------------------------------------------------------------- | | `schema_version` | int | Always `1` in this release. | | `event` | string | One of `"tick_start"`, `"tick_end"`, `"tick_skip"`. | | `root_id` | string | Stable `root_id` from the root's `root.toml`. | | `tick_index` | int | Zero-based per-root counter. | | `time` | string | RFC3339 UTC timestamp (slog's default `time` attribute). | | `level` | string | `slog` level (`INFO`, `WARN`, `ERROR`). | | `msg` | string | Human-readable summary (`tick_start`, `tick_end`, `tick_skip`). | Event-specific fields: | Event | Field | Notes | | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `tick_end` | `duration_ms` | int — wall-clock ms | | `tick_end` | `outcome` | `"ok"` or `"error"` | | `tick_end` | `error` | string — present only when `outcome="error"` | | `tick_end` | `sync` | object — nested envelope; identical shape to `dropbear sync --json`, including `schema_version=1`, `manifest_id`, `uploaded`, `downloaded`, etc. | | `tick_skip` | `reason` | `"offline"` in this release. | | all | `trigger` | `"startup"` (first tick), `"watcher"` (filesystem-watcher wakeup), `"head-poll"` (remote head poller wakeup), or `"interval"` (backstop timer). | When `log_format = "text"` is set, the same attributes are rendered in `slog.TextHandler`'s single-line `key=value` form. When `log_format = "pretty"` is set, records render as a coloured header line followed by indented attribute lines — convenient for interactive runs: ``` 18:54:56 WARN local-change root=slice2 tick=42 path=hello.mom status=modified ``` Colour is enabled automatically when stdout is a TTY. Stderr is reserved for process-level errors (config validation failures, panics surfaced outside a tick); routine tick records never land there. ## `jq` recipes Filter to one root: ```bash dropbear daemon /a /b /c | jq -c 'select(.root_id == "my-laptop-photos")' ``` Show only successful tick outcomes with their manifest IDs: ```bash dropbear daemon /root | jq -c 'select(.event=="tick_end" and .outcome=="ok") | {time, manifest: .sync.manifest_id, dur: .duration_ms}' ``` Watch for offline skips: ```bash dropbear daemon /root | jq -c 'select(.event=="tick_skip")' ``` Total bytes uploaded across the session: ```bash dropbear daemon /root | jq -s '[.[] | select(.event=="tick_end" and .outcome=="ok") | .sync.uploaded.bytes] | add' ``` ## Looking at the daemon When `status_api = true` (the default) the daemon binds a read-only HTTP/JSON listener on `127.0.0.1` (ephemeral port by default) and writes a runtime handoff file at `os.UserConfigDir()/dropbear/runtime/daemon.json` (mode `0600`). The file carries `{schema_version, pid, url, token, started_at}`. Clean shutdown removes it; a crashed daemon leaves it behind by design — `dropbear daemon status` checks the recorded PID before sending the token anywhere. The simplest consumer is the bundled CLI: ``` $ dropbear daemon status pid 4321 started 2026-05-28T10:30:00-04:00 schema 1 work online 3s ago via watcher (147ms) ~/work archive online 2m ago via interval (1.2s) ~/archive ``` Exit codes: `0` daemon running and every root is healthy; `1` daemon running but at least one root reports `online=false` or a recent error; `2` daemon not running (file missing, PID dead, or unreachable). Pass `--json` to skip the human formatter and emit the raw response envelope. For the browser, every endpoint except `GET /` and `GET /v1/healthz` requires `Authorization: Bearer `. The bundled one-page UI receives the token via URL fragment (`http://127.0.0.1:NNNN/#t=`), reads `location.hash` once, clears the fragment, and attaches the bearer header to every fetch. Fragments are never sent in the HTTP request by browsers, so the token does not land in server logs or `Referer` headers. Use `dropbear daemon status --ui` to print the URL with the token fragment already assembled — e.g. `open "$(dropbear daemon status --ui)"` on macOS, `xdg-open "$(dropbear daemon status --ui)"` on Linux. For server installs that put the API behind a reverse proxy (e.g. nginx adding TLS in front), set `status_api_addr = "127.0.0.1:9876"` in `daemon.toml` to pin the port. The bind is still loopback-only; the proxy is responsible for whatever external-facing authentication makes sense in that environment. When the proxy handles auth (e.g. nginx `auth_basic`), set `status_api_require_auth = false` and the daemon skips its own bearer-token gate — the proxy can then forward unauthenticated requests upstream. With auth disabled the runtime file's `token` is empty, `daemon status --ui` prints the URL without a `#t=` fragment, and the embedded HTML omits the Authorization header on its fetches. Only disable when (a) the bind is loopback-only and (b) the proxy is the only path that can reach the listener. Endpoints (all return `application/json; charset=utf-8` with `schema_version=1`): | Method | Path | Auth | Notes | | ------ | ---------------------------------- | ---- | ------------------------------------------------------------- | | `GET` | `/v1/healthz` | no | `{"ok": true}` — liveness only. | | `GET` | `/v1/status` | yes | `{schema_version, pid, started_at, roots[]}`. | | `GET` | `/v1/roots` | yes | Same `roots[]` without the envelope. | | `GET` | `/v1/roots/{rootID}` | yes | Per-root snapshot + most recent `recent_ticks`. | | `GET` | `/v1/roots/{rootID}/ticks?limit=K` | yes | Ring buffer slice; `K` clamped to `[1, status_tick_history]`. | | `GET` | `/` | no | Embedded one-page web UI. | The API is read-only by design. There is no "sync now," no config edit, no remote start/stop. Set `status_api = false` to disable it entirely (no listener, no runtime file, no per-root tick history retained in memory). ## How the daemon decides to sync Each supervised root runs its own tick loop. Between ticks the loop blocks on four signals at once: (a) shutdown (gating context cancellation), (b) a wakeup from the per-root filesystem watcher, (c) a wakeup from the per-root remote head poller, or (d) the interval timer firing. Whichever fires first drives the next tick. **Filesystem watcher** (`fsnotify`-based): recursively subscribes to every non-ignored directory under the root at startup. When a file changes, it coalesces fsnotify events with a `watcher_debounce` quiet period (default `500ms`) and emits a single wakeup. **Remote head poller**: periodically LISTs `roots//devices/` in the bucket, filters for `/head.json` entries, and compares each remote head's ETag (or size, when ETag is empty) against an in-memory last-seen map. Any change emits a wakeup. The cadence is adaptive: it starts at `head_poll_interval` (default `10s`), doubles after every consecutive no-change poll up to `head_poll_max_interval` (default `120s`), and snaps back to base on any observed change. The first poll after startup is silent (populates the map without emitting a wakeup — the startup tick already consumed initial remote state). LIST errors log at warn and don't crash, don't mutate state, and don't advance the backoff. **Interval**: the backstop. Now a **ceiling since last activity** — the timer restarts from zero after every tick completes (regardless of which trigger fired it). A busy root effectively never hits the backstop; a quiet root scans once per `interval` as a safety net for events that the watcher or poller dropped. Tick records carry a `trigger` attribute identifying the cause: - `"startup"` — the first tick after process start. - `"watcher"` — the loop unblocked because the filesystem watcher emitted a wakeup. - `"head-poll"` — the loop unblocked because the head poller observed a remote head change. - `"interval"` — the loop unblocked because the interval expired without any activity. ### Caveats - **Ignored directories are not watched until restart.** The watcher loads `.dropbear/ignore` at startup; a directory matched at that moment (e.g. `node_modules`) is not subscribed at all. If you later remove the ignore rule, writes under that directory will not trigger wakeups until you restart the daemon — but the interval backstop still picks them up at the next scheduled scan. - **Inotify watch limits (Linux).** The default per-user limit is 8192 watches. Trees with more directories than that should `.dropbear/ignore` their vendor / build dirs (good practice regardless). The daemon does not pre-check the limit; an exhaustion error at startup logs and exits `73`. - **A watcher open failure aborts startup.** Same all-or-nothing semantics as state-open failures: every already-opened handle is closed and the daemon exits without entering any tick loop. ## What this slice does NOT do These are explicit non-goals for v0.3 slices 2–3; later slices add them: - No per-event scoped scans — a wakeup still triggers a full `upload.Run` walk. Scoped scans are real work for a future slice. - No hot reload of `.dropbear/ignore`; restart to pick up changes. - No per-root override of `watcher_debounce`, `head_poll_interval`, or `head_poll_max_interval`; all three are process-wide. - No persistent storage of last-seen remote head ETags across daemon restarts. A restart causes one no-op tick per root as the poller re-primes its map. - No bucket-side event notifications. Support is uneven across S3-compatible backends (great on AWS/MinIO, partial on Ceph/SeaweedFS/R2/B2, absent on DigitalOcean Spaces/Wasabi/Storj). Client-side polling is the portable choice; per-backend event adapters are a future optimisation. - (Earlier non-goal: "no systemd / launchd unit files" — shipped in slice 5; see _Installing as a user service_ below.) - No reload-without-restart; `SIGHUP` is unmapped. - No auto-discovery beyond the config file or positional args. - Windows is untested. fsnotify itself supports Windows, and these slices use no Unix-specific syscalls; other dropbear subsystems have not been validated there. Reports welcome. ## Installing as a user service `dropbear daemon install` writes the platform-appropriate user-level service file, optionally drops a starter `daemon.toml`, and activates the service. It is opt-in — no other command lays down a service file. ### Environment for the daemon systemd and launchd do not inherit your interactive shell environment, so the daemon needs S3 credentials handed to it explicitly. `dropbear daemon install` reads them from a single canonical file: | Platform | Path | | -------- | ---- | | Linux | `~/.config/dropbear/env` | | macOS | `~/Library/Application Support/dropbear/env` | The file is **mandatory** — `install` refuses to write a service file without it (exit `64`). It must satisfy: - Mode `0600` (group/other bits clear). - Define non-empty values for both `DROPBEAR_S3_ACCESS_KEY` and `DROPBEAR_S3_SECRET_KEY`. The pre-flight parser accepts both `KEY=VALUE` and `export KEY=VALUE`, strips a matched pair of single or double quotes around values, ignores blank lines and `#` comments. Recommended shape: ``` DROPBEAR_S3_ACCESS_KEY=AKIA... DROPBEAR_S3_SECRET_KEY=... ``` The rendered service file loads this file at start: systemd uses `EnvironmentFile=`; launchd wraps the binary in `sh -c 'set -a; . ; set +a; exec daemon'`, which exports every assignment into the daemon's environment. The same env-file format works on both platforms. `uninstall` does not touch the env file. `install` never writes to it either — populating credentials is on you. ### Linux (systemd user unit) ``` dropbear daemon install ``` Writes `~/.config/systemd/user/dropbear.service`, then runs `systemctl --user enable --now dropbear.service`. The unit runs as the invoking user, restarts on failure (`Restart=on-failure`, `RestartSec=5s`), and inherits stdout/stderr into the user journal. Inspect with: ``` systemctl --user status dropbear journalctl --user -u dropbear -f ``` ### macOS (LaunchAgent) ``` dropbear daemon install ``` Writes `~/Library/LaunchAgents/net.tfks.dropbear.plist`, creates `~/Library/Logs/dropbear/`, and runs `launchctl bootstrap gui/$UID …`. `KeepAlive = {SuccessfulExit = false}` restarts the daemon on crash but not after intentional shutdown. Inspect with: ``` launchctl print gui/$UID/net.tfks.dropbear tail -F ~/Library/Logs/dropbear/out.log ~/Library/Logs/dropbear/err.log ``` ### `--no-enable` ``` dropbear daemon install --no-enable ``` Writes the service file but skips activation. The activation command is printed to stdout for the user to run by hand. Useful for inspecting the file before letting systemd/launchd touch it. ### Starter `daemon.toml` If `os.UserConfigDir()/dropbear/daemon.toml` does not exist, `install` writes a commented-out starter showing every knob at its default plus an example `[[roots]]` block. An existing `daemon.toml` is never modified — `install` is safe to rerun after a binary upgrade. ### Upgrades Replace the binary and rerun `dropbear daemon install`. The new `os.Executable()` path is rendered into the unit file. systemd / launchd pick up the change on next start. ### Uninstall ``` dropbear daemon uninstall ``` Stops and removes the user-level service. Idempotent — re-running on an already-uninstalled host exits `0`. Does NOT remove `daemon.toml`, the runtime file, the state SQLite, or `~/Library/Logs/dropbear/`. ### Exit codes - `0` — success (including idempotent uninstall of missing file). - `64` — validation/usage error (unsupported platform, empty paths). - `65` — write failure. - `66` — activation or deactivation failure. The unit/plist file is left on disk so you can inspect and rerun. ### Known failure modes - **`install` exits `64` with "env file … not found".** Create the env file at the canonical path with mode 0600 and populate both required keys; rerun. - **`install` exits `64` with "mode … is too permissive".** `chmod 600 ` and rerun. - **`install` exits `64` with "missing required key".** Add the named `DROPBEAR_S3_*` key to the env file. - **`install` exits `64` with "value for … is empty".** The key is present but its value is empty after stripping quotes/whitespace. Fill it in. - **Service fails to start after upgrade.** The unit file references the binary's old path. Rerun `dropbear daemon install` to refresh. - **`install` succeeds but a second daemon refuses to start.** A foreground `dropbear daemon` is already running and holds the status-API runtime file. Stop the foreground process first. - **Windows.** Not supported by this slice; `install` exits `64`. ## Self-bootstrapping roots After `dropbear daemon install`, the recommended path to a working sync is to extend `daemon.toml` with a `[roots.init]` block per root and restart the service. The daemon will create the root directory if absent, initialise its identity, and start syncing — no out-of-band `dropbear init` invocation required. ```ini [[roots]] path = "~/Dropbear" [roots.init] root_id = "dropbear-personal" device_id = "$DROPBEAR_DEVICE_ID" mode = "bidirectional" [roots.init.remote] bucket = "my-bucket" endpoint = "https://abc.r2.cloudflarestorage.com" region = "auto" ``` Every string field — including `path` — runs through tilde + `$VAR`/`${VAR}` expansion at parse time. An unset env var that leaves a field empty is rejected with `exit 64`; nothing fails silently. The `device_id` env-var pattern is the load-bearing reason this works: a `daemon.toml` you share via dotfiles stays portable across machines because the per-host identifier comes from the environment, not the file. ### `root.toml` is authoritative once it exists `[roots.init]` is consulted **only** when the root directory has no `root.toml` yet. The moment `root.toml` is on disk — whether written by the bootstrap, by a previous `dropbear init`, or shipped in via backup — the daemon ignores `[roots.init]` entirely. There is no reconcile loop, no diff, no warning. If you need to change a `device_id` or remote endpoint on an already-bootstrapped root, edit `root.toml` directly (or rerun `dropbear init --force` from the CLI); changing `[roots.init]` after the fact has no effect. ### What the bootstrap does On daemon startup, for each `[[roots]]` entry whose `root.toml` is absent **and** which carries a `[roots.init]` block: 1. `MkdirAll(path, 0755)` — creates the root directory if needed. 2. Validates the resolved `[roots.init].remote` block via `remoteconfig.Parse` (same validation path `dropbear init` uses). 3. Writes `root.toml` and the default `.dropbear/ignore` (`config.Init` codepath — bit-for-bit identical to a hand-run `dropbear init`). 4. Emits a single `slog.Warn` record: ```json { "time": "…", "level": "WARN", "msg": "bootstrapped root", "root": "/Users/e/Dropbear", "root_id": "dropbear-personal", "device_id": "laptop-erik", "mode": "bidirectional" } ``` WARN (not INFO) so the record surfaces even in production deployments that suppress info-level chatter. Bootstrap is rare and mutates on-disk state; the record gives you one verifiable trace per host per root. ### Failure modes | Failure | Exit | | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `[roots.init]` missing a required field, malformed mode, unparseable endpoint, or any field empty after env expansion | `64` | | `MkdirAll` fails (permissions, disk full) during bootstrap | `65` | | `config.Init` rejects the remote (`remoteconfig.Parse` failure) or fails to write `root.toml` | the diagnostic-derived code from `config.ExitCodeFor` | All-or-nothing: bootstrap failure for **any** root aborts startup before any per-root tick loop starts. Partial bootstraps are not a state the daemon can be in.