Files
imladris/docs/security-plan.md
T
kyle 01018fcbda docs: fleet security plan v1.3 + tailnet ACL baseline
Phase 0 findings: password auth confirmed on (straylight, dixie), ACL
default allow-all, funnel-for-all, titan stale public funnel, gitea
public edge on nginx 1.18.0. Plan: 6 pillars, 6 phases, decisions and
assumptions register inline.
2026-11-01 13:35:35 -08:00

18 KiB

Fleet Security Plan

Status: DRAFT v1.3 (2026-11-01). Author: straylight. Owner: Kyle. Scope: the tailnet (scylla-hammerhead.ts.net) and everything hanging off it, including the public edge. Dates are out of scope; sequencing is capability-and-dependency with exit criteria.

Decisions (Kyle, 2026-11-01)

  • LAN is trusted: single-occupant apartment. LAN-reachable services are low priority to scope down; Internet-facing and tailnet segmentation still matter.
  • gitea stays public by design: it is the public GitHub alternative; code is shared there intentionally. Plan hardens the edge, does not tunnel it.
  • Tailscale account is MFA'd: tied to Google account with hardware MFA. Single-identity takeover risk is mitigated at the IdP layer.
  • Plan approved; Phase 0 underway.
  • Device approval already required (manual key generation or node sign-in) — the silent-enrolment path is already closed.
  • Key expiry OFF fleet-wide (Kyle: more annoying than useful). Reopen condition: after Phase 2 tagging — tagged devices are key-expiry-exempt by default (Tailscale docs, verified 2026-11-01), so re-enabling ages only phones/laptops, which re-auth through the already-required approval; surface upcoming expiries via the Tailscale API in Phase-4 alerting.
  • Tailscale SSH unused; Kyle manually manages authorized_keys and dislikes it → P1 target: adopt TS SSH, retire manual key files.
  • titan's live public Funnel identified: Kyle's old LLM-chat web UI. Backend dead (502 via relay 2026-11-01) but public DNS + cert + relay route remain — retire (Phase 1).
  • No tailnet audit log available on the current Tailscale plan.

Executive summary

The fleet's core posture is already sound: WireGuard-encrypted tailnet as the only real network, LUKS on most hosts, declarative NixOS config (rebuild-from- flake is a genuine recovery superpower), firewall discipline that scopes service ports to tailscale0 in most places, and reactive CVE hygiene (eMudhra CA blacklist in configuration.nix).

The plan's job is to close four gaps, in order:

  1. Unreviewed blast radius — the tailnet ACL policy and the full exposure inventory (who can reach what) have never been written down.
  2. Cheap exposure wins — a handful of verified open doors (SSH password auth confirmed enabled on straylight and dixie; dated nginx on the public gitea edge) that cost an afternoon to close.
  3. Secrets lifecycle — no secrets manager by design; .env files and LoadCredential scattered across hosts, with no inventory or rotation story. Acceptable only if known and bounded.
  4. Detection — fleet metrics (VictoriaMetrics/vmalert on orion) exist but carry no security signals; today a brute-force or a new node joining the tailnet is invisible.

Threat model, stated plainly: single-user fleet, Kyle is the only human. The adversary is not an insider; it is (a) an Internet-scanner hitting the public edge, (b) commodity malware/phishing landing on a desktop or phone, (c) a supply-chain hit on a fleet service (container image, nixpkgs input, gitea plugin), and (d) physical loss of a portable machine. Passwordless doas fleet-wide (deliberate) means shell on any machine ≈ root on that machine, so the plan spends its effort on access paths (network reachability, credentials, edge software) rather than in-host privilege boundaries — that trade-off is accepted, not a finding.

Current state (verified 2026-11-01 unless tagged)

Tailnet

  • Tailnet scylla-hammerhead.ts.net, single user isomk@, 26 peers.
  • Composition: 3 NixOS server-class hosts with agents (straylight, titan, talos), NixOS fleet boxes in ~/src/nixos (8 hosts incl. orion, rift, svc, sk), no-agent hosts (dixie, rift-class compose boxes), 2 cloud Linux hosts with public direct connections (phobos — 95.216.41.208, svc — 71.19.144.164), synology NAS, homeassistant, phones/tablets (iOS, Android), Apple TV, and fs offering exit node.
  • hello.ts.net from hello@ is a foreign tagged test node — leftover demo.
  • titan Funnel (verified live 2026-11-01): titan.scylla-hammerhead.ts.net is the only tailnet name in public DNS (funnel relays 208.111.34.11, 208.111.35.209). It was Kyle's old LLM-chat web UI; backend now 502s but the public name, cert, and relay route persist — a dormant public door. All other hosts NXDOMAIN publicly = no other funnels.
  • ACL policy: confirmed default allow-all (exported 2026-11-01, baseline saved at docs/tailscale-acl.hujson). Three consequences: (1) every device — phones, Apple TV, NAS, homeassistant — can reach every port on every server (SSH, postgres, exporters, llama API); (2) the Funnel nodeAttr is granted to all members, so any compromised device can silently open a public internet endpoint to a local service — a bypass of the entire "nothing public except gitea" posture; (3) default Tailscale SSH rule is active (members → own devices, nonroot+root) — benign for a single-user tailnet, but it means tailscaled on any Kyle-owned device answers SSH.
  • Mattermost on straylight exposed via tailscale serve (443 → 127.0.0.1:8065), tailnet-only. No Funnel endpoints active. This is the pattern to standardize on.

Verified exposures (probed 2026-11-01)

straylight (ss + sshd -T + curl):

Exposure Evidence Assessment
SSH :22 on 0.0.0.0, password auth ON (PasswordAuthentication yes, KbdInteractiveAuthentication yes) sshd -T via doas The top cheap close; scanners brute-force this daily. Same confirmed on dixie
open-webui :8080 globally firewall allowedTCPPorts = [443 8080]; curl http://192.168.88.170:8080 → 200 LAN-only reachability, trusted LAN → downgraded to hygiene. Auth status [TODO]
llama API :11434/:11435 on 0.0.0.0 ss; firewall scopes to tailscale0 Bind is wide but firewall gates it — acceptable pattern
node exporter :9100, vmagent :8420 on * ss Metrics unauthenticated on whatever the per-host firewall allows [TODO: scope per host]
Syncthing :22000 TCP + :21027 UDP global ss; enabled on all hosts Global discovery — by design, but it is a mesh credential shared by every device
LLMNR :5355 on 0.0.0.0 ss (also on dixie) Spoofable name resolution; LAN trusted → downgraded to hygiene
Public gitea git.wntrmute.dev HTTP 200, nginx/1.18.0 (Ubuntu) at 95.216.224.239 Public by design; nginx is 2020-era. [ASSUMPTION: gitea VM is phobos — IP differs from phobos's direct address; verify which host]

dixie (SSH batch probe, 2026-11-01):

  • PasswordAuthentication yes — confirmed here too.
  • Listeners: postgres :5432 on 0.0.0.0 (repo comment says firewall scopes it to tailscale0 — config-level, not runtime-verified [TODO: nft list ruleset — first attempt returned empty]), llama :11434 + :11433 on 0.0.0.0, tether :11435 on *, node exporter :9100 on *, :9835 on * [TODO: identify], syncthing :22000, LLMNR :5355. Syncthing GUI correctly loopback-only (:8384 on 127.0.0.1).
  • No .env or compose files in kyle's home — services are NixOS-native; secrets presumably live in flake/config. [TODO: confirm where]

Config-level facts (from ~/src/nixos)

  • Base configuration.nix: PermitRootLogin = "prohibit-password"; passwordAuthentication = false is commented out → upstream default "yes" applies, now runtime-confirmed on two hosts.
  • security.doas: kyle, noPass, keepEnv — base pattern, confirmed by Kyle as deliberate and fleet-wide.
  • No secrets manager (AGENTS.md: "No secrets manager — SSH keys via gpg-agent"). Secrets live in compose .env files, LoadCredential= units, ~/<svc>.env, gitea deploy keys, and agent/API credentials in Hermes profiles and ~/.config/sift keys.
  • No fail2ban/crowdsec anywhere in the repo (searched).
  • LUKS on all hosts except sk and svc (AGENTS.md).
  • GUARDIAN STAR compartmentalization via separate Hermes profile — an existing, deliberate compartment boundary.

Pillars

P1 — Access control

Goal: every access path to every host is enumerated, and each is either key-only or tailnet-gated.

  • Tailnet ACLs. Single-user tailnet with phones/TV/NAS on it. Device tags (server / workstation / mobile / iot) with ACL grants between tags beats per-node rules and stays reviewable. Minimum policy worth enforcing: mobile/TV/IoT cannot reach server SSH/DB ports; cloud hosts (phobos, svc) reach only what they demonstrably need; synology/homeassistant reachable from Kyle's devices only.
  • SSH — adopt Tailscale SSH, retire manual authorized_keys (Kyle's stated pain). Mechanism verified against the nixos-26.05 module: services.tailscale.extraSetFlags = [ "--ssh" ] (runs tailscale set --ssh via tailscaled-set.service; the extraUpFlags path only fires with an authKeyFile). The ACL ssh section already exists in the policy — after Phase 2 it gates SSH by tag, replacing per-host key files with the one reviewed policy. Migration order: enable --ssh per host → verify a login → drop that host's authorized_keys → finally disable sshd password auth everywhere regardless (confirmed yes on straylight + dixie). Cloud hosts keeping public :22 stay on plain sshd, keys-only + AllowUsers kyle; everything else can close LAN :22 entirely.
  • doas. Accepted as-is. The one soft underbelly worth mapping: "gpg-agent SSH keys forwarded from a desktop" — a compromised desktop with the agent socket reaches every host. [TODO: inventory which hosts' authorized_keys accept which keys.]
  • Agent credentials. Hermes agents, Ornith, MCP wrappers, and sift hold live API keys with file-system reach. GUARDIAN STAR profile separation is the model; extend it only where a compartment is actually warranted (YAGNI otherwise).

P2 — Exposure discipline

Goal: one pattern for publishing a service, and a public edge that is current.

  • Standard pattern (already mostly followed): loopback bind + tailscale serve for tailnet services; tailscale-IP bind for compose stacks; tailscale0-scoped firewall holes; Funnel only for deliberate public exposure. Write this into the repo as a short doc so it's policy, not folklore (much of it already lives in the tailnet-service-ops skill — promote the relevant parts to a repo doc).
  • Hygiene (LAN trusted, low urgency): scope 8080 and exporters to tailscale0 or loopback for consistency; LLMNR off unless something needs it; orion already scopes exporters — extend the pattern. Identify dixie's :9835.
  • gitea edge (public by design — harden, don't hide). It is the entire public attack surface and the public code channel. Update the host (nginx 1.18.0 → current), confirm gitea itself is current, disable public sign-up, add fail2ban (or equivalent). Public repos are intended; public instance admin is not.
  • Funnel policy: if/when used, each Funnel endpoint gets an entry in the exposure inventory with an owner and a review note.

P3 — Secrets lifecycle

Goal: know every secret, where it lives, and how to rotate it — before deciding whether to adopt a manager.

  • Phase it: (1) inventory (grep compose/env/units/profiles → one table: secret, host, file, rotation path), (2) rotation runbook for the crown jewels (gitea admin, Mattermost DB, cloud-host root keys, API keys in agents), (3) only then decide on agenix/sops-nix. Alternatives, honestly: status quo + inventory (zero new machinery, no rotation automation), agenix (age-encrypted files in the nix repo, fits flake workflow, no external service), sops-nix (same idea, more keys/MOF flexibility, heavier). For a single-user fleet the flake-native age approach is the natural fit, but it earns its keep only if the inventory shows sprawl worth encrypting — that's what phase 1 answers.
  • gpg-agent as SSH keystore: single point of compromise for fleet access. Verify the master key has an offline backup (paper/hardware) and that subkeys are revocable without the master.

P4 — Patch & supply chain

Goal: dated software is visible before it's exploited, not after.

  • nixpkgs pinned to nixos-26.05 channel — add a cadence for nixos-rebuild rollouts (the eMudhra response shows the mechanism works; make it routine). The infra-security-watchdog skill already exists for release/advisory watching — wire it to the services that actually run here (gitea, Mattermost, Syncthing, tailscale, open-webui, llama.cpp, VictoriaMetrics).
  • Container images on the compose hosts (rift Honcho stack, mattermost) are hand-managed → no automatic updates; put image tags in the same watchdog list.
  • gitea is both the fleet's source-of-truth and public — a compromise there poisons every host on next nixos-rebuild/deploy-key pull. Treat it as the highest-value supply-chain asset: current version, 2FA enforced, signed-release awareness, admin audit log retained.

P5 — Detection

Goal: the boring attacks are noticed. Build on VictoriaMetrics/vmalert (orion) — no new logging stack (YAGNI).

  • vmalert rules from existing exporters: per-host "new listening port" deltas (node exporter), sustained SSH auth failures (needs a textfile or journal exporter — small piece of new work [TODO: pick the cheapest journal→metrics bridge]), sudden node/egress anomalies.
  • Tailscale admin audit log (node additions, ACL changes) — review cadence rather than tooling.
  • Syncthing/gitea/Mattermost admin-event awareness rides the same review.

P6 — Recovery

Goal: any single host is loss-tolerant and rebuildable, provably.

  • Strong foundation: flake rebuild + disko. The untested parts: LUKS header backups, gpg master key offline backup, synology backup coverage vs. the actually-irreplacable data (Honcho DBs on rift, gitea repos, notes), RELIC USB drive procedure (skill exists). One drill per quarter-class cadence: rebuild one non-critical host from flake + restore its data, and time it.

Roadmap (dependency-ordered; exit criteria, no dates)

  • Phase 0 — Exposure inventory & ACL review (underway). Tailscale ACL exported ✓ (baseline in repo); console review: ss + firewall audit on every NixOS host (straylight ✓, dixie ✓ listeners / firewall ruleset pending; titan + talos queried via their agents; orion/rift via network probes); sshd -T fleet-wide (straylight ✓, dixie ✓); secrets inventory (P3 phase 1). Exit: docs/security-inventory.md in this repo listing every listener, every public endpoint, every secret class, each tagged verified/date.
  • Phase 1 — Cheap closes. Uncomment passwordAuthentication = false; update gitea edge (nginx + gitea current, sign-up off, fail2ban on); delete hello.ts.net node; retire titan's stale Funnel (tailscale funnel --reset / tailscale serve reset on titan, then confirm public DNS goes NXDOMAIN); hygiene pass (8080/exporters/LLMNR scoped, identify dixie :9835). Exit: re-run the Phase-0 probes and the tables above show all green.
  • Phase 2 — Segmentation. Device tags + ACL grants in the Tailscale admin console (mobile/IoT ↔ servers, cloud-host egress restrictions, exit-node policy). First, cheapest cut: restrict the Funnel nodeAttr from autogroup:members to tagged servers — closes the any-compromised-device-can-publish-publicly hole before full segmentation. Exit: ACL file exported into this repo for review; deliberate-allow list documented.
  • Phase 3 — Secrets. Rotation runbooks for crown jewels; decide agenix/sops/status-quo on inventory evidence. Exit: every jewel has a tested rotation path; decision recorded here.
  • Phase 4 — Detection. vmalert security rules + Tailscale audit-log review cadence. Exit: one deliberate red-team probe (e.g. wrong-key SSH burst) pages/flags.
  • Phase 5 — Recovery drills. LUKS header + gpg offline backups verified; one host rebuilt from flake end-to-end. Exit: timed drill notes in this repo.

Risks (top, by expected loss)

  1. gitea compromise → poison pill for every host (deploy keys + rebuild). Highest severity, moderate likelihood (public, dated edge). Stays public by design, so this risk is managed by edge currency + hardening, not removed.
  2. Desktop phishing → gpg-agent keys → fleet SSH. doas makes each hop root. Mitigated by ACLs + key inventory, not by host-internal gates.
  3. Unrotated API keys in agents/compose .env → quiet exfiltration channels with billing and data access.
  4. Single tailnet identity (isomk@) — Tailscale account takeover is fleet takeover. Mitigated: Google IdP with hardware MFA (confirmed). Residual risk bounded only by ACLs — which are currently allow-all with Funnel granted to every member; Phase 2 is the bound.
  5. No detection — everything above compounds because nothing alerts.

Assumptions register

# Assumption Basis Status
A1 Tailnet ACLs are default allow-all console export 2026-11-01 confirmed 2026-11-01 — default policy verbatim (allow-all ACL, funnel-for-all, default SSH rule); baseline at docs/tailscale-acl.hujson
A2 SSH password auth is enabled (default) on most hosts passwordAuthentication = false commented in base config confirmed 2026-11-01 on straylight + dixie via sshd -T; other hosts pending
A3 git.wntrmute.dev runs on phobos Hetzner-range IP, but ≠ phobos's direct IP open
A4 open-webui :8080 has some auth enabled not probed (would need creds) open (downgraded — LAN trusted)
A5 Exporters on other hosts are firewall-scoped like orion orion pattern in repo; straylight's are *-bound open
A6 LAN is untrusted/shared MikroTik-style subnet; unstated superseded 2026-11-01 — single-occupant apartment, LAN trusted; LAN-exposure findings downgraded
A7 Tailscale account has MFA unstated confirmed 2026-11-01 — Google account, hardware MFA

References

  • Verified-exposure probes: this session, 2026-11-01 (tailscale status, ss -tlnp/-ulnp, sshd -T, curl probes from straylight; SSH batch probe of dixie).
  • ~/src/nixos: configuration.nix, hw/straylight/default.nix, docs/metrics-plan.md, AGENTS.md.
  • Fleet conventions: tailnet-service-ops skill (exposure patterns, per-host procedures).