Skip to content

Security: soodoh/home-lab

Security

docs/security.md

Security boundary

Protected output

Never print or commit:

  • decrypted SOPS values or resolved Compose configuration;
  • OpenTofu state, plan JSON or saved plans;
  • provider tokens, recovery credentials or bundle plaintext;
  • private application paths, filenames or user content from recovery output.

Use no_log: true, private temporary directories and bounded summaries. Remove private temporary output when the current run ends.

Secrets

secrets/production.sops.yaml is the structured encrypted application source. Omada mail has a separate desired authority in secrets/omada-mail.sops.json: dedicated SMTP token, server, sender and required recipients. Native recipients are derived from independently owned administrator account email/alert subscriptions; SMTP approval is not an account mutation grant. The mail role requires those recipients before SMTP writes and never PATCHes notifications or accounts. Capture and observation do not activate SMTP. The approval-gated native Ansible mail interface decrypts on the controller and protects requests with no_log and verified HTTPS; SMTP tokens are masked by the API and cannot be compared during a normal observation. Omada has no documented SMTP password-file reader; do not inject these values into Compose or claim that a secret mount configures its native controller settings. Authentik OAuth client secrets are instead authoritative in infrastructure/tofu/authentik/client-secrets.sops.json. Site convergence reads that source on the controller to render Home Assistant's protected !secret entry and Vaultwarden's protected client-secret file. Only the public client ID is added to Compose's interpolation environment, from Authentik's reviewed desired.json. Calibre-Web-Automated's OIDC setting remains an explicitly manual admin step; do not treat an Authentik secret update as its completed rotation. Compose deployment decrypts SOPS inputs through community.sops on the controller; provide the age identity through SOPS_AGE_KEY_FILE. The identity is never copied into Git or to deployment artifacts. For a local controller, separate plan/apply provider identities may be exported from a mode-0600 gitignored .env or a protected credential store. The local file is persistent plaintext: protect it independently, never commit or log it, and rotate credentials if it is exposed. The dedicated tailnet Traefik ACME access key is held as ciphertext in secrets/production.sops.yaml and rendered into a root-only AWS shared credentials file. Only private Traefik mounts it; public ingress must never inherit it. services/credentials.json declares raw/structured credential files, actual host reader permissions and the explicit remaining interpolation inputs. Openfit is excluded: its two secrets remain in the protected production.env and its container environment. Other migrated inputs use existing application/image file interfaces, not custom entrypoints. LinuxServer and PostgreSQL still export file contents inside the process tree; file delivery is not universal memory or log redaction. Host files are plaintext, individually mounted read-only and included in encrypted backup/recovery scope. See the delivery audit for native readers and caveats. Staging and production certificates have separate root-only ACME stores excluded from Restic. Production TLS and tailnet grants do not prove CI/application authentication, remote GOST destination denials or UDP/HTTP3 negative paths; verify these against current live state before relying on them. The non-AWS helper reads exported provider variables without prompting and creates disposable per-run credential files. Do not keep those files across sessions. Proxmox disk and USB identities are reviewed in Git; fresh read-only host observation must match them and the host's sealed hardware inputs before planning Proxmox changes.

The repository may contain public age recipients, public certificates and CA certificates when they are trust inputs rather than proof of a completed action. The Omada provider and export helper use omada.ts.diloreto.com on tailnet TCP 443 and strictly verify private Traefik's publicly trusted certificate. For the separate backend hop, private Traefik trusts only the observed Omada self-signed public certificate in services/data/traefik-tailnet/omada.pem and verifies its Omada DNS name over a dedicated two-container Docker bridge. No Omada private key enters Git or the controller. Before deployment, compare the live backend certificate fingerprint and SAN against the committed trust input; certificate rotation must update the reviewed trust input before the old one expires (October 2028). A mismatch must fail closed; do not disable backend or client-side TLS verification, substitute a LAN/tailnet address, or restore a hostname alias.

Mindwtr access

Authentik gates the web UI at todo.diloreto.com, not Cloud sync clients. /v1/* reaches Mindwtr Cloud without web forward-auth; regular API and sync requests require the fixed Cloud token. A calendar feed created through the authenticated API uses its own revocable URL token at /v1/calendar/<token>.ics. Treat that URL as a secret; do not require the fixed token on the feed route. Unknown and revoked feed URLs must not return task data.

CLIProxyAPI tailnet boundary

Private Traefik terminates HTTPS for CLIProxyAPI at llm.ts.diloreto.com on tailnet TCP 443; the old Tailscale Serve port 8444 is closed. Traefik reaches cli-proxy-api:8317 on the Docker proxy bridge; the existing host binding remains limited to 127.0.0.1:8317. The API key and separate full-privilege management key are age-encrypted in secrets/cli-proxy-api.sops.yaml and rendered only to a root-owned protected runtime config. The management UI has no account-only role: its key can read, replace or delete OAuth auth files and change settings. Treat UI settings edits as drift from Git and SOPS. OAuth refresh/session files in the bind mount are sensitive and intentionally enter the encrypted local/NFS/Proton backup chain. Never expose the OAuth callback port on any host network interface. The public gost.diloreto.com WebSocket proxy is now configured for tailscale.com and its subdomains on ports 80/443 and *.ts.diloreto.com:443 only; the old any-port MagicDNS matcher is removed from the running host config. Authentik gates the proxy handshake with the existing service-account app password; the API and management keys remain separate service credentials. The proxy credential permits attempts to reach all listed private routes, including Omada, Proxmox and Z-Wave. Omada and Proxmox use their native logins; Z-Wave's private UI stays behind its existing Authentik application and group binding. The relay handshake does not authenticate a backend. Tailscale evaluates relay egress as the Docker-host node, not the work Mac. GOST cannot filter paths inside HTTPS tunnels. The operator reports work-Mac first-hop LLM/Omada application access and Tailscale coordination working, but its system PAC is off and remote authenticated whitelist refusal remains untested. Treat loss of the proxy credential as loss of this network boundary and revoke it as described below.

SSH and privilege

ansible/inventory/hosts.yml fixes the Tailscale MagicDNS names, ansible-deploy users, host-key aliases and credential-free OpenSSH client policy. Tailscale authenticates the source identity and Tailscale SSH maps it to the local account; native authorized keys remain absent. Host changes use Ansible become. See deployment access for the local path and the narrow GitHub workload-identity boundary.

Observe independent console access before work that can change Tailscale, networking, firewall, storage or boot behavior.

Tailscale coordination proxy

The public gost.diloreto.com endpoint reaches GOST only through the Authentik embedded proxy. Authentik intercepts HTTP Basic credentials on the WebSocket handshake and authorizes only the gost-proxy-user service account's exact application binding. Its gost-proxy app password is created manually, has no expiry, and is stored only as age ciphertext in the work-Mac dotfiles profile; it is not a Compose or OpenTofu input. Revoke both the old tailscale-control-proxy app password and the new app password when applicable; terminate active WebSockets when immediate invalidation is required.

GOST has no published host port and no reusable server-side credential. Its reviewed whitelist permits tailscale.com and subdomains on TCP 80/443, plus *.ts.diloreto.com:443; it does not declare any-port MagicDNS access or arbitrary Internet destinations. Docker's default DNS cannot resolve MagicDNS. Only GOST uses 100.100.100.100 for peer names and the host-observed 1.1.1.1 fallback for public Tailscale control names; site convergence checks the resolver pair and host's current DNS. Do not disable client TLS verification or remove Authentik's identity gate. Check the live remote whitelist with an isolated authenticated client without a local bypass; source alone does not prove what that server currently denies. A stolen relay credential can attempt all listed private application routes through the Docker host's identity, subject to application authentication and Tailscale policy. Proxmox needs its native login; Z-Wave remains Authentik-protected. The bridged private Traefik reaches Authentik's embedded proxy at authentik-server:9000 without a host port; Z-Wave's UI port 8091 has no published host binding. Docker publishes private Traefik's TCP 443 only on the reviewed Docker-host Tailscale IPv4.

OpenTofu state and plans

Active roots use remote S3 backends. Run tofu init and a fresh plan for every session. Keep saved plans in a private temporary directory and never inspect them by printing raw JSON. Use scripts/inspect-tofu-plan for bounded policy inspection.

A local-state-only root is not deployable. Migrate or retire its ownership before removing local state.

Authentik omits OAuth client secrets from its provider API response unless the caller has provider change permission. The Authentik OpenTofu plan identity therefore needs that permission on the four managed OAuth providers to plan secret rotations without false updates on every refresh. Grant it on those objects rather than globally if the live RBAC model permits. This is an explicit exception to a read-only plan identity: protect its credential as a mutating credential, and do not plan until the independently owned grant has been reviewed and installed. The plan-input preflight refuses an identity that cannot read all managed OAuth secrets without printing them. The apply identity remains separate. Both identities' provider reads and any saved plans/state can contain client secrets; keep them private.

The Arr applications expose only one full-privilege API key per instance. The owner explicitly accepts a narrow exception to separate plan/apply provider identities for the Servarr root: both operations use that same key, solely from a fresh mode-0700 controller session prepared from protected SOPS, with a reviewed saved plan and separately approved apply. This does not make a plan read-only; protect the controller accordingly. The qBittorrent and SABnzbd connection secrets become sensitive OpenTofu resource attributes, so protect remote state and private plans in addition to the encrypted SOPS sources. Private Arr provider routes should forward only native API paths over tailnet HTTPS. The native API key remains essential authorization: the GOST relay can attempt the permitted *.ts.diloreto.com:443 private routes after its own handshake, so a private hostname or relay authentication alone is insufficient.

Recovery material

Restic passwords, Proton credentials, age identities and encrypted recovery bundles belong in independent protected storage. Bundle metadata contains only identities freshly observed from the selected live repository chain. It does not contain or hash historical Git receipts.

There aren't any published security advisories