diff --git a/CLAUDE.md b/CLAUDE.md index 097ba69..8d3a185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,8 @@ phased delivery plan; new work should move the code toward it, not entrench the - Collector drivers subclass `CollectorDriver` (`worker/src/devicevault_worker/drivers/base.py`) with a pydantic `Params` model, and raise `TransientError` (retried) or `PermanentError`. SSH CLI platforms subclass `drivers.ssh.NetmikoDriver` (a Netmiko `device_type`, the - commands, and regexes for volatile lines); see `drivers/cisco.py`. Built-ins are listed in + commands, and regexes for volatile lines); see `drivers/cisco.py`. A driver that connects over SSH must + require `target.host_key` when it is set and report the key it saw in `log.host_key` (pinned on first use). Built-ins are listed in `drivers/__init__.py`; third-party drivers register under the `devicevault.collectors` entry point. Storage drivers subclass `storage.drivers.base.StorageDriver` (see `storage/drivers/local.py`, `s3.py`), stream content (`store` reads a binary file, `read` writes one; backups can be hundreds of MB) and register under diff --git a/deploy/orchestrator/orchestrator.env.example b/deploy/orchestrator/orchestrator.env.example index 79c619d..2cae1f4 100644 --- a/deploy/orchestrator/orchestrator.env.example +++ b/deploy/orchestrator/orchestrator.env.example @@ -9,6 +9,10 @@ DV_DEBUG=false DV_ALLOWED_HOSTS=devicevault.example.com # Full origins, required when the UI is served over HTTPS by a front proxy DV_CSRF_TRUSTED_ORIGINS=https://devicevault.example.com +# Address (or network, comma separated) of the TLS proxy or load balancer in front of the +# web service. Without it every user appears to come from the proxy: one client's failed +# sign-ins lock everyone out, and API tokens' allowed networks check the proxy's address. +DV_TRUSTED_PROXIES= DV_TIMEZONE=Australia/Sydney DV_LOG_LEVEL=INFO diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 98d95b0..0e9d790 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -101,6 +101,8 @@ Message formats are in `protocol/src/devicevault_protocol/registration.py`. **Trust on first use:** the worker pins the orchestrator key from the first *approved* assignment that verifies against it, so the trust decision coincides with the admin's approval. After that, assignments signed with any other key are rejected, and so is every task (`device.collect`, `storage.store`, `storage.read`) not signed by the pinned key. Tasks rejected this way fail immediately and are not retried. The Workers page shows the orchestrator's fingerprint and flags a worker that pinned a different one. Changing `DV_SIGNING_KEY` means resetting every worker's state. +**Device host keys** follow the same model. A device's SSH host key is pinned at its first collected backup: the collector reports the key the device presented in its signed `collected` event, and the orchestrator stores it on the device if it has none, accepting it only from a collector of the device's own collection group. Every later `job.collect` task carries the pinned key in its signed payload, and the collector refuses any other key before authenticating (a permanent failure, reason `auth`). An administrator of the device's group forgets the key when the device is replaced or re-keyed. Collectors keep no known-hosts state of their own. + **Limitations:** - Broker credentials are shared per role, so revocation is enforced cryptographically (signatures and keys), not at the broker. Per-worker broker users can be added later. - Celery remote control stays enabled because Flower needs it, so anyone holding broker credentials can still send Celery control commands (for example, to stop a worker consuming). They cannot forge assignments or tasks. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f5e482a..dbfffa8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -79,6 +79,16 @@ not exist, the bootstrap admin from `DV_ADMIN_USERNAME` / `DV_ADMIN_PASSWORD` as a member of it. Put a TLS-terminating proxy or load balancer in front of `web`, and set `DV_ALLOWED_HOSTS` and `DV_CSRF_TRUSTED_ORIGINS` to match. +Set `DV_TRUSTED_PROXIES` to that proxy's address (or its network, comma separated +for several). The `web` service records the address that connected to it, which +is then the proxy rather than the user; with the proxy listed, the orchestrator +takes the address the proxy reports instead. Without it every user appears to come +from the proxy: sign-in lockout by address then locks everyone out together, an API +token's allowed networks are compared with the proxy, and the audit log shows only +the proxy. The proxy must append to (or replace) `X-Forwarded-For`, as nginx, +HAProxy (`option forwardfor`), Traefik and cloud load balancers do. Never list an +address that clients could connect from themselves. + ### Users and access Sign-in uses a server-side session: an HttpOnly cookie (`Secure` unless @@ -86,7 +96,8 @@ Sign-in uses a server-side session: an HttpOnly cookie (`Secure` unless orchestrator node shares it. Sessions end after `DV_SESSION_IDLE_MIN` without use and `DV_SESSION_MAX_HOURS` after sign-in. Repeated failed sign-ins lock out a username or client address for `DV_LOGIN_LOCKOUT_MIN`; the client address is the -last `X-Forwarded-For` hop, which the `web` service adds. +last `X-Forwarded-For` hop that is not one of `DV_TRUSTED_PROXIES` (with none set, +the hop the `web` service adds). **API tokens** let scripts and monitoring call the API without a session: send `Authorization: Bearer dvt_...`. A token acts as its owner and never beyond their @@ -199,6 +210,23 @@ log line matches. To automate approval: `manage.py approve_worker - **Revoke** stops a worker consuming. **Forget** removes it; if it is still running it registers again as pending. +### Device SSH host keys + +Collectors pin each device's SSH host key on first use. A device's first backup +over SSH accepts the key the device presents; the orchestrator records it on the +device (audited as `device.host_key_pin`, and noted in that backup's log) and sends +it with every later backup. From then on a collector connects only if the device +presents exactly that key, before any username or password is sent, so a host +answering in the device's place cannot collect its credentials. A backup refused +this way fails at once with the reason `auth` and is not retried. + +The device's page shows the pinned key's type and `SHA256:` fingerprint; compare it +with the device's own (`show crypto key mypubkey` and the like) when the first +backup is not made over a network you trust. When a device is replaced or its keys +are regenerated, an administrator of its device group chooses **Forget host key** +there (audited as `device.host_key_forget`), and the next backup pins the new key. +Only a collector of the device's own collection group can pin a key. + ### Storage targets Under **Admin Settings > Storage Targets**, create a target per place backups go: @@ -722,6 +750,7 @@ secrets). The file takes precedence. | `DV_DEBUG` | `false` | Development mode; never in production | | `DV_ALLOWED_HOSTS` | none (`*` in debug) | Hostnames the UI is served on | | `DV_CSRF_TRUSTED_ORIGINS` | none | Full origins (`https://host`) behind HTTPS | +| `DV_TRUSTED_PROXIES` | none | Addresses or networks of the proxies in front of `web`; the client address is the last `X-Forwarded-For` hop that is not one of them | | `DV_TIMEZONE` | `UTC` | Display timezone (storage is always UTC) | | `DV_LOG_LEVEL` | `INFO` | | | `DV_DB_ENGINE` | `sqlite` | `sqlite` or `postgres` | diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 7c7887f..77f3184 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -164,6 +164,8 @@ export const devicesApi = { deactivate: async (id: Id, reason: string) => (await http.post(`/devices/${id}/deactivate/`, { reason })).data, reactivate: async (id: Id) => (await http.post(`/devices/${id}/reactivate/`)).data, + /** Drop the pinned SSH host key (a replaced or re-keyed device); the next backup pins the new one. */ + forgetHostKey: async (id: Id) => (await http.post(`/devices/${id}/forget_host_key/`)).data, /** Global administrators: unpins and deletes every backup of an inactive device. `confirm` is its name. */ purge: async (id: Id, confirm: string) => (await http.post(`/devices/${id}/purge/`, { confirm })).data, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 75d0445..ec9e9db 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -225,6 +225,11 @@ interface DeviceBase { storage_target: string | null credential: Id | null schedule: Id | null + /** The SSH host key pinned at the device's first backup, which collectors then require; '' while none is. */ + ssh_host_key_type: string + /** `SHA256:...`, as the device and `ssh-keygen -l` show it. */ + ssh_host_key_fingerprint: string + ssh_host_key_pinned_at: IsoDateTime | null /** What the signed-in user holds on this device's group. */ user_permissions: GrantFlag[] inactive_at: IsoDateTime | null diff --git a/frontend/src/pages/EditDevicePage.vue b/frontend/src/pages/EditDevicePage.vue index d82653d..c06a3c5 100644 --- a/frontend/src/pages/EditDevicePage.vue +++ b/frontend/src/pages/EditDevicePage.vue @@ -28,6 +28,33 @@ {{ device.last_backup_time ? formatDateTime(device.last_backup_time) : 'Never' }} +
+
SSH host key
+ +
+ None pinned yet. The key the device presents at its first backup over SSH is pinned and required from + then on. +
+
@@ -211,6 +238,7 @@