# HOMELAB-HORIZON KNOWLEDGE BASE **Generated:** 2026-05-21 **Commit:** 7ee9871 **Branch:** main ## OVERVIEW Self-contained homelab management: WireGuard VPN, split-horizon DNS (dnsmasq + Route53/Name.com/Cloudflare), HAProxy reverse proxy with Let's Encrypt SSL (via `lego`), service health monitoring, HA peer sync, MCP tool server. Single Go binary embedding a React SPA. Runs on Ubuntu/Debian; bare-metal systemd or Docker. ## STRUCTURE ``` homelab-horizon/ ├── cmd/homelab-horizon/ # Entry point, CLI flags, systemd install, MCP stdio mode ├── cmd/hz-probe/ # Outside-in vantage agent (serve/install/gen-cert/fingerprint), deployed OUTSIDE the homelab ├── internal/ │ ├── server/ # HTTP handlers (~22 handler files), MCP server, routing │ ├── apitypes/ # API DTOs → tygo-generated into ui/src/api/generated-types.ts │ ├── config/ # JSON config: loading, validation, derivation │ ├── wireguard/ # WG config parsing, key gen, interface mgmt │ ├── haproxy/ # Config generation, reload, per-service timeouts │ ├── letsencrypt/ # lego ACME wrapper, cert scheduling, renewal │ ├── dnsmasq/ # Internal DNS config, hosts file management │ ├── dns/ # Provider abstraction (Route53/Name.com/Cloudflare) │ ├── route53/ # AWS Route53 API, public IP detection, IPv6 check │ ├── acme/ # ACME provider factory for lego │ ├── iptables/ # Rule generator + classifier + reconciler (expected/stale/blessed/unknown) │ ├── autoheal/ # On-startup + periodic system fixes (forwarding, masq, chains) │ ├── monitor/ # TCP/HTTP/TLS health checks, remote-vantage polling, ntfy notifications │ ├── probe/ # hz-probe wire types, probes, agent, and hz's polling client │ ├── system/ # FileSystem/CommandRunner interfaces (for testing) │ └── qr/ # QR code SVG generation ├── ui/ # React SPA (Vite + MUI + TanStack Router/Query) │ ├── src/routes/ # File-based routes: dashboard, services, vpn, domains, checks, settings, mfa, bans │ ├── src/components/ # AppLayout, LoginPage, IPTablesTab, SystemHealthTab, SystemMetricsCard, Sync*, ChecksHistory, RemoteVantages │ ├── src/api/ # client, hooks (TanStack Query), generated-types.ts (from tygo), schemas (zod) │ └── embed.go # //go:embed all:dist → served at /app/ ├── test/integration/ # Dry-run integration tests ├── plan/plan.md # Active roadmap ├── examples/ # ha-same-subnet, ha-site-to-site, simple ├── docker/ # demo-config.json └── Dockerfile # Vanilla Ubuntu, autoheal installs deps on first run ``` ## WHERE TO LOOK | Task | Location | Notes | |------|----------|-------| | Add HTTP route | `internal/server/server.go` | `setupRoutes()` — ~83 `/api/v1/*` routes registered here | | Add API handler | `internal/server/handlers_api_*.go` | Group by domain (auth, system, system_fix, iptables, vpn, metrics, mutations, settings) | | Add non-API handler | `internal/server/handlers_*.go` | auth, ban, backup, deploy, domains, ha, haproxy, mfa, peer, services, wireguard, zones | | Add API DTO | `internal/apitypes/types.go` | Run `make generate` (tygo) to push TS types into `ui/src/api/generated-types.ts` | | Add UI page | `ui/src/routes/*.tsx` | TanStack file-based router (`routeTree.gen.ts` regenerates) | | Add UI component | `ui/src/components/*.tsx` | MUI v7 | | Call API from UI | `ui/src/api/hooks.ts` | TanStack Query hooks; `client.ts` is the fetch wrapper | | Add config field | `internal/config/config.go` | JSON tag, then update `derive.go` if it affects DNS/HAProxy/SSL | | DNS provider | `internal/dns/` | Implement `Provider` interface | | iptables rule | `internal/iptables/rules.go` | Add to `ExpectedRules(cfg)`; classifier + reconciler pick it up automatically | | L4 port forward | `internal/config/forwards.go` (shape, `ValidateForwards`, reserved ports), `internal/iptables/forwards.go` (rules) | Per-service `forwards`. Rules live in owned chains `HZ-PREROUTING`/`HZ-POSTROUTING`/`HZ-FORWARD`, rebuilt atomically; the three jumps are in `StaleRules` so they go when the last forward does. The generator re-checks reserved ports and LAN containment itself. Commands go through `runIptables`, which tests record (`reconcile_forwards_test.go`) | | System fixer | `internal/server/handlers_api_system_fix.go` | Paired with a check in `handlers_api_system.go` | | Outside-in probe | `internal/probe/` | `run.go` adds a probe kind; `agent.go` is the remote side, `client.go` is hz's. Folded into checks by `internal/monitor/remote.go` | | Vantage CRUD | `internal/server/handlers_api_remotes.go` | `/api/v1/checks/remotes{,/add,/update,/delete,/test}`; UI in `ui/src/components/RemoteVantages.tsx` | | Check history shape | `internal/monitor/history.go` | Bucketed + run-length encoded server-side. Rendered by `ui/src/components/ChecksHistory.tsx` — keep it RLE, do not expand to a node per column | | hz-probe systemd unit | `cmd/hz-probe/install.go` | `unitTemplate` is the only copy — `hz-probe show-systemd` prints it, there is no checked-in unit file to drift | | Vantage installer | `internal/server/hz_probe_install_script.go` | curl\|bash at `/admin/hz-probe/install`; binaries at `/admin/hz-probe/bin/-` from `hzbin` (`-tags hzembed`). Mirrors the hz CLI installer beside it | | Push ingest | `internal/server/handlers_probe_report.go` | `POST /api/v1/probe/report`, public vhost. Auto-registers on a valid grant; `monitor.AcceptPushedResults` folds the batch and returns the count the agent uses as its watermark | | Install auth | `internal/server/install_grants.go` | Both installers are kiosk-vhost-only; the binaries need a grant (a minted vantage token or the admin token). `installBaseURL` is what the UI must build commands from | | Test mocks | `internal/system/` | `DryRunFileSystem`, `DryRunCommandRunner` | | MCP tool | `internal/server/mcp.go` | stdio mode entered via `-no-mcp=false` (default on) | ## CONVENTIONS - **API types are the source of truth.** Add to `internal/apitypes/`, run `make generate`, which pins a fork (`go run github.com/iodesystems/tygo@v0.2.21-iode.1`). Never hand-edit `ui/src/api/generated-types.ts` — it has a generated header. - **UI is React, not templates.** Old `templates_*.go` are gone (React migration, see `memory/project_react_migration.md`). - **Config derivation**: Services/zones derive DNS mappings, HAProxy backends, SSL domains via `internal/config/derive.go`. Add a service → DNS + HAProxy + cert all flow from it. - **JSON tags**: All config fields use `json:"snake_case"` with `omitempty`. - **Version injection**: `main.Version` set via `-ldflags` at build time. - **CSRF**: API mutations require `X-CSRF-Token` header; React client handles this automatically via `ui/src/api/client.ts`. - **Auth modes**: session cookie (UI), Bearer admin token (API/scripts), VPN-admin (LAN/VPN clients on the admin range). `backupAuthMiddleware` accepts all three for ops scripts. - **Peer sync (HA)**: Each peer runs its own classifier/reconciler locally and reports counts via fleet status. Bless is per-host (`BlessedIPTablesRules` excluded from peer sync). `RemoteProbes` is excluded too — the token belongs to a host that peer chose. - **Outside-in direction**: two modes, and **push is the default**. In push the agent reports to `POST /api/v1/probe/report` (public vhost) and needs nothing inbound — no public address, no open port, no certificate of its own — so it works behind NAT. In pull hz dials the agent, which costs all of those plus a pinned certificate re-done on every address change. hz accepting the POST adds no exposure: it arrives on the same public edge these checks exist to verify. Either way, only public facts (served hostnames, public IP) cross the wire, and check rows are prefixed `ext:` and carry `Vantage`. - **A push vantage registers itself.** First report bearing a valid install grant creates the config entry, named by the agent — so the operator pastes nothing back. The token is the identity; the name in the body is a label, and results are filed under the token's vantage so a misnamed agent cannot report as another. - **Push has no poll loop, so a watchdog replaces reachability**: `sweepPushVantages` marks a vantage failed after three missed intervals. Without it a dead agent reads as every target frozen on its last good result. "Never reported" stays distinct from "stopped reporting". - **The port map is what hz was told, not what is listening.** `DeriveHostPortMap` is derived from config; a host also runs things nobody registered. `hz ports next` now verifies its candidate against the host (`/api/v1/ports/observed`, `internal/portscan`) before offering it, and `ports list` shows listening-but-unregistered ports. Without that it suggested a port a neighbouring daemon already held, and the service that took the advice restart-looped on a failed bind. - **`dormant` is a reserved slot, not a disabled check.** The name, certificate and proxy entry stay; nothing is expected behind them. Backend reachability goes quiet (`svc:` check, and the vantage's `https` target), DNS and TLS stay live — those are what the reservation consists of and what has to be intact when the slot comes back. `StatusDormant` is distinct from `StatusDisabled`: one is a fact about the service, the other is "stop telling me about this check". **Every edit client must round-trip `dormant`** — the edit path assigns it from the request, so omitting it un-parks the service. `ServiceSchema` in `ui/src/api/schemas.ts` must list it too, or zod strips it before the form ever sees it. - **`external_dns.ips` is a pin, not a default.** Empty means "track this host's public IP" and is what you want; a set value publishes that address forever, including after the host renumbers. `config.PinnedIPWarnings` surfaces pins that are not this host's address (and pins that redundantly match it, which break on the next change). `ExternalDNSResp.ConfiguredIPs` is the pinned value; `IPs` is the *effective* one and falls back to the host IP — reading `IPs` alone cannot tell the two apart. - **The agent never executes what hz sends it.** hz reports the build it holds (`PushResponse.AgentVersion`, which is `s.version` — the embedded binaries are cross-compiled in the same build); the agent logs that it is behind and does nothing. Updating is a separate root `hz-probe-update.timer` running `hz-probe update`, because the agent runs as `DynamicUser` and cannot write its own binary or restart its unit. Keep it that way: an agent that acted on hz's word would turn a compromised hz into code execution on a host outside the network. - **A registered vantage's own token authorises the binary download.** Install grants last an hour, which is right for a human pasting a command and useless for a nightly timer. Pull-mode installs get no timer — hz holds no token for them. - **Vantage targets are per service, not per host IP.** `publicTargets` expects what the record is *supposed* to say (`GetPublicIPsForService`, which honours a pin), so a deliberately-pinned record is not reported as broken. A service with no proxy but with `external_dns` gets a DNS-only target — the WireGuard endpoint record matters and has nothing serving HTTP behind it. - **Check history is bucketed and run-length encoded on the server** (`internal/monitor/history.go`). The raw per-sample form made the chart quadratic in fleet size — its x-axis was the union of every check's timestamps and every check got a cell in every column. Two vantages was ~250 KB per refetch and six figures of DOM nodes. Grouping in the UI is by `Vantage`; steady rows collapse behind a toggle. - **Reloading the monitor**: `ReloadRemotes(cfg)` for a vantage change — it restarts only the vantages that actually changed and preserves all other history. `Reload(cfg)` is the blunt one; it stops every check and clears everything. `Monitor.config` is an `atomic.Pointer` so the narrow reload can swap it without stopping the local check goroutines. - **Vantage tokens are write-only**: `RemoteProbeResp` returns `hasToken`, never the token. An update with an empty `token` keeps the stored one, so editing a URL never requires re-typing a credential the UI cannot show. hz *mints* the token (`/api/v1/checks/remotes/token`) so the install command can carry it — nothing is persisted until the vantage is saved. - **Install routes are kiosk-only, and the binaries are not anonymous.** The installer is fetched by a host outside the network, so it lives on the public-facing (`kiosk_url`) vhost, not the admin hostname — on the admin name it 404s. The scripts stay anonymous (a few KB, no secret); the 7MB binaries require an install grant: a token hz minted for a command, redeemable for an hour, or the shared admin token (unless it is disabled). With no `kiosk_url` set there is no second vhost, so any host is allowed — the weaker posture an operator opted into by having no portal. **Never build an install command from `window.location.origin`** — it is the admin origin and will 404; use the `installBase` the mint endpoint returns. - **TOFU is interactive only**: `probe.Client.Observe` accepts an unverified certificate so its fingerprint can be shown to a person, and is set *only* by the test endpoint. The poll loop never sets it — there a certificate either chains to a public CA or matches the pin the operator approved. `Observe` with `PinSHA256` set is an error, not a precedence rule. - **`hzbin` serves two tools**: `hz` and `hz-probe` share `bin/`, and `"hz-"` is a prefix of `"hz-probe-"` — `Available` must not list one as the other. Guarded by `embed_on_test.go` (runs only under the tag). - **iptables rule model**: every live rule classified as `expected` / `stale` / `blessed` / `unknown`. Autoheal removes stale, adds missing expected, never touches blessed/unknown. See `plan/plan.md` for the full model. - **Port forwards never touch INPUT, never flush a chain horizon does not own, never change a policy.** `rebuildChain` refuses any chain outside `ownedChains`, and built-in chains are only edited one rule at a time (`-I 1` / `-D`). `reconcile_forwards_test.go` records every command and fails on anything else. **Every edit client must round-trip `forwards`** (hz `respToRequest`, the UI form, MCP edit): the edit path replaces them from the request, like `dormant`. ## ANTI-PATTERNS (THIS PROJECT) - **Never edit `ui/src/api/generated-types.ts` or `ui/src/routeTree.gen.ts`** — both regenerated. - **No `pkg/`**: everything is `internal/`, no public API surface. - **`handlers_haproxy.go` deprecated UI bits**: HAProxy/Route53 are auto-derived from services; legacy direct-management handlers remain only for compatibility. - **Test coverage skewed**: `dns/`, `acme/`, `autoheal/`, `apitypes/` have no unit tests; most of `server/` handlers are untested. Add tests when you touch them. - **No CI yet** — `.github/workflows/` is empty. Run `make test-all` locally before commit. ## COMMANDS ```bash make # Build for current platform (ui + go) make ui # npm ci + npm run build (tygo runs first via `generate`) make generate # tygo: Go apitypes → TS generated-types.ts make build-go # Go-only build with stub ui/dist (no npm required) make build-probe # Build hz-probe for this platform make build-probe-all # Cross-compile hz-probe (amd64, arm64, armv7) into dist/ make build-all # Cross-compile: amd64, arm64, armv7 make run # Backend + Vite dev server concurrently make run-backend # Go only (serves built SPA at /app/) make run-frontend # Vite only (proxies API to :8080) make test # Run all Go tests make test-unit # internal/ tests only make test-integration # test/integration/ only make test-coverage # Coverage report → coverage.out make check # go vet + go fmt make test-all # test-unit + test-integration + check sudo ./homelab-horizon # Requires root for WG/ports 80/443 ./homelab-horizon -dry-run # Preview changes without applying ./homelab-horizon -check # Interactive system check ./homelab-horizon -config-template # Print commented config template ./homelab-horizon -iam-policy # Print Route53 IAM policy template ./homelab-horizon -show-systemd # Print generated systemd unit ./homelab-horizon -no-mcp # Disable MCP stdio (default: enabled) ./homelab-horizon -version ``` ## TESTING - **Dry-run mode**: `system.DryRunFileSystem` + `system.DryRunCommandRunner` mock all I/O. Most package tests use these. - **Test files**: `*_test.go` alongside implementation. Highest coverage: `iptables/`, `config/`, `haproxy/`, `peer_sync` in `server/`. - **Integration**: `test/integration/dry_run_test.go` exercises the full server lifecycle in dry-run. - **No frontend tests yet.** ## NOTES - **Requires sudo**: WireGuard interface, ports 80/443, iptables. Drops nothing; runs as root. - **Config search order**: `/etc/homelab-horizon/config.json`, `/etc/homelab-horizon.json`, `./config.json`, `./homelab-horizon.json`. - **Admin token**: Generated on first run, written next to the config as `.token`. - **Health endpoint**: `GET /health` (shallow liveness). `GET /api/v1/system/health` is the aggregated component check (used by the System Health UI tab). - **IPv6 check**: `route53.CheckIPv6()` via api6.ipify.org. - **MCP**: stdio MCP server enabled by default; surfaces tools for services, DNS, HAProxy, system health. Disable with `-no-mcp`. - **Backup**: `GET/POST /admin/backup/{export,import}` — zip-format snapshot of config + tokens + state. Auth via Bearer or session. - **HA**: `/api/v1/ha/status` reports per-peer `iptables_summary {expected, stale, blessed, unknown}`. Fleet tab badges peers with drift.