Compare commits
1 Commits
claude/cha
...
hermes-onb
| Author | SHA1 | Date | |
|---|---|---|---|
| 4560e25bd7 |
@@ -1,96 +0,0 @@
|
||||
# NOMOS.md — Agent persona for homelab clients
|
||||
|
||||
This file is the canonical agent persona for **all** AI agents running on
|
||||
machines in the **hubris** homelab. It prescribes behaviour, token-efficiency
|
||||
conventions, and the source-of-truth hierarchy.
|
||||
|
||||
## Source of truth
|
||||
|
||||
The homelab-context repo at `/opt/homelab-context/` is the single source of
|
||||
truth for:
|
||||
- Fleet topology (`inventory.yaml`, `inventory.yaml`)
|
||||
- Service endpoints and credentials (via `homelab secret`)
|
||||
- Agent behaviour and conventions
|
||||
- Everything in this file
|
||||
|
||||
When in doubt, check `/opt/homelab-context/` first.
|
||||
|
||||
## Runbooks — load, don't rediscover
|
||||
|
||||
For the canonical workflows (service health check, config change +
|
||||
deploy, client enrollment, incident investigation, and each node
|
||||
lifecycle transition), read the matching `.agents/skills/<name>/SKILL.md` before
|
||||
acting. Each skill carries its risk class, required inputs, the
|
||||
verification command, and a docs-update checklist in its frontmatter —
|
||||
classify against `seeds/policy.yaml` using that risk class before any
|
||||
mutation. Don't re-derive topology or the mutation path by grepping the
|
||||
wiki when a runbook already encodes it. See [OIKOS.md](OIKOS.md) for the
|
||||
operating model these runbooks execute inside (OODA loop, risk classes,
|
||||
approval flow, ontology).
|
||||
|
||||
## Agent type — how this file gets loaded
|
||||
|
||||
| Agent | Loading mechanism |
|
||||
|-------|------------------|
|
||||
| **Nomos** | `tools/setup-nomos-soul.sh` (auto-setup) → provisions `~/.nomos/SOUL.md` from this file |
|
||||
| **Goose** | `.goosehints` symlink at `~/.config/goose/.goosehints` → `/opt/homelab-context/NOMOS.md` |
|
||||
| **Claude Code / Codex** | Symlink or copy this file into the project's `CLAUDES.md` / `.claude` instructions |
|
||||
|
||||
**Do not edit SOUL.md or .goosehints directly.** Edit this file in the
|
||||
homelab-context repo instead. Changes propagate to all clients on the next
|
||||
sync (`sudo homelab sync`).
|
||||
|
||||
---
|
||||
|
||||
## Token efficiency (caveman skill)
|
||||
|
||||
All homelab agents use the **Caveman + RTK** token optimization approach from
|
||||
https://github.com/adityahimaone/hermes-agent-rtk-caveman.
|
||||
|
||||
### Before running any CLI command, ask:
|
||||
|
||||
1. **Is there a caveman wrapper equivalent?** Use the wrapper for token-efficient
|
||||
output. Available wrappers (installed at `~/bin/caveman_wrapper.sh`):
|
||||
- `~/bin/caveman_wrapper.sh git-status` — compact git status
|
||||
- `~/bin/caveman_wrapper.sh git-log [n]` — compact git log
|
||||
- `~/bin/caveman_wrapper.sh lint [target]` — compact lint results
|
||||
- `~/bin/caveman_wrapper.sh test-results [cmd]` — compact test results
|
||||
|
||||
2. **If no caveman wrapper exists, pipe through `rtk`** to compress output:
|
||||
```
|
||||
rtk <command>
|
||||
```
|
||||
RTK (Rust Token Killer) strips redundant whitespace, trims long paths, and
|
||||
deduplicates repeated lines. This reduces token usage by 60-90% on CLI
|
||||
operations.
|
||||
|
||||
3. **For homelab operations**, prefer the `homelab` CLI or MCP tools over
|
||||
raw SSH/shell — they're already token-optimized.
|
||||
|
||||
### Templates
|
||||
|
||||
Caveman templates live at `~/templates/`:
|
||||
- `git_status.txt` — compact git status format
|
||||
- `git_log.txt` — compact git log format
|
||||
- `lint_results.txt` — compact ESLint format
|
||||
- `test_results.txt` — compact vitest/jest format
|
||||
|
||||
### When to skip caveman/rtk
|
||||
|
||||
- Interactive commands (editors, prompts) — let human-readable output pass
|
||||
- Commands with no output — skip entirely
|
||||
- When you need the exact raw output for post-processing
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
ls ~/bin/caveman_wrapper.sh && echo "caveman ready"
|
||||
```
|
||||
|
||||
## Important note for Nomos agents
|
||||
|
||||
If you are reading this as a Nomos agent, your SOUL.md was auto-provisioned
|
||||
by `tools/setup-nomos-soul.sh`. This file is the canonical original — you
|
||||
can verify the content matches or re-provision by running:
|
||||
|
||||
bash /opt/homelab-context/tools/setup-nomos-soul.sh
|
||||
190
.agents/OIKOS.md
190
.agents/OIKOS.md
@@ -1,190 +0,0 @@
|
||||
# Oikos — the operating model
|
||||
|
||||
Oikos (Greek: *household*) is the agent operating system layered on this
|
||||
repo. It is not new infrastructure: `inventory.yaml` is the kernel data
|
||||
structure, the `homelab` CLI and MCP server are the syscall surface, and
|
||||
this page defines the rules everything above them follows.
|
||||
|
||||
Read this after [AGENTS.md](../AGENTS.md). Machine-readable companions:
|
||||
[seeds/ontology.yaml](../seeds/ontology.yaml) (systems model),
|
||||
[seeds/policy.yaml](../seeds/policy.yaml) (risk & approval).
|
||||
|
||||
## The kernel loop: OODA
|
||||
|
||||
Every Oikos activity — scheduled probe, agent task, operator request — is
|
||||
one pass through **Observe → Orient → Decide → Act**:
|
||||
|
||||
1. **Observe** — probes, drift detectors, and agent findings produce
|
||||
**Signals** (structured records, not loose messages): pending updates,
|
||||
high temperature, low disk, service down, cert expiry, stale backup,
|
||||
inventory drift.
|
||||
2. **Orient** — walk the ontology graph: what entity is affected, what
|
||||
depends on it (blast radius), its lifecycle state, whether a runbook
|
||||
matches, what the ledger says about past attempts.
|
||||
3. **Decide** — the classifier scores **risk class × blast radius ×
|
||||
confidence** and routes:
|
||||
- **auto-act**: within autonomy policy, high confidence, contained radius
|
||||
- **escalate**: operator approval via Matrix (✅/❌ reaction) or the
|
||||
Oikos Console's `/approvals` page (destructive actions additionally
|
||||
need a typed confirmation phrase either way)
|
||||
- **queue**: informational — console + reports
|
||||
The classifier can only *lower* autonomy relative to policy, never raise
|
||||
it. When in doubt, escalate.
|
||||
4. **Act** — execute through `homelab` commands or runbooks (never ad-hoc
|
||||
SSH), then **verify** with the action's verification command, write a
|
||||
**ledger** entry, resolve the Signal, and update docs in the same session.
|
||||
|
||||
## Primitives
|
||||
|
||||
| Primitive | What it is | Lives in |
|
||||
|---|---|---|
|
||||
| Host / Service | topology entities | `inventory.yaml` |
|
||||
| Secret | SOPS+age encrypted value, per-client recipients | `secrets/` + `.sops.yaml` |
|
||||
| Runbook | executable workflow with risk class + verification | `.agents/skills/<name>/SKILL.md` |
|
||||
| Signal | something needing attention, with lifecycle | DB `signals` table |
|
||||
| Change | one mutation: who, what, risk, approval, verification | DB `audit_log` + `executions` tables |
|
||||
| Approval | short-TTL signed grant for a gated action | DB `approvals` table |
|
||||
| Incident | investigation narrative | DB `knowledge_entities` (seeded from investigations) |
|
||||
| Knowledge | document, runbook, investigation | DB `knowledge_entities` (seeded from `seeds/knowledge.yaml`) |
|
||||
| Plan | design doc for non-trivial work | `plans/` |
|
||||
| Agent | enrolled client identity = its age pubkey | `inventory.yaml` + `.sops.yaml` |
|
||||
|
||||
## Risk classes (enforced, not advisory)
|
||||
|
||||
From [seeds/policy.yaml](../seeds/policy.yaml):
|
||||
|
||||
- **read_only** — status, logs, docs, inventory. Unattended.
|
||||
- **reversible_low** — restart, cache clear, sync pull. Unattended + ledger.
|
||||
- **config_mutation** — tracked-config edits (commit+push, never local),
|
||||
deploys, upgrades, DNS/ingress changes. Operator approval.
|
||||
- **destructive** — destroy, format, wipe, rotate, revoke. Approval +
|
||||
typed confirmation phrase.
|
||||
|
||||
Lifecycle gates modify these: `provisioning` nodes are freely mutable
|
||||
(nothing depends on them); `deprecated` nodes accept no new dependents;
|
||||
anything touching a `destroyed` node is drift.
|
||||
|
||||
## The systems model
|
||||
|
||||
Eight domains — physical, compute, network, storage, software,
|
||||
identity & access, operations, external — cover everything in the lab;
|
||||
entities are connected by typed edges (`hosts`, `provides`, `mounts`,
|
||||
`stores-on`, `routes-to`, `can-decrypt`, `depends-on`, `backs-up-to`, …)
|
||||
defined in [seeds/ontology.yaml](../seeds/ontology.yaml). Rule of
|
||||
completeness: **if it can break, be changed, or hold data, it has an
|
||||
entity and edges.** Blast-radius questions ("what breaks if strong goes
|
||||
down?") are graph walks, not doc archaeology.
|
||||
|
||||
Nodes move through an explicit lifecycle —
|
||||
`planned → provisioning → active → migrating → deprecated → destroyed` —
|
||||
stored as `state:` in inventory (absent = active). Destroyed nodes live in
|
||||
the `archaeology:` section. Each transition is a runbook checklist;
|
||||
deprecation completes only when inbound edges reach zero.
|
||||
|
||||
Generated views: the live topology graph at `oikos.hubris.network/graph`
|
||||
via the API's `/api/v1/graph` endpoint, and the Mermaid export at
|
||||
`GET /api/v1/graph?format=mermaid`.
|
||||
|
||||
## Conventions carried forward
|
||||
|
||||
- Inventory is the truth; live state wins over narrative docs.
|
||||
- Prefer `homelab` CLI and MCP over ad-hoc SSH.
|
||||
- Meaningful changes update docs in the same session.
|
||||
- Secrets are decrypted locally via per-client keys; never into docs/comments.
|
||||
- Tracked configs change by commit + push, not local edits.
|
||||
- Netbird is the preferred mesh path for new traffic.
|
||||
- Agents are terse ([caveman.md](shared/caveman.md)), verify claims, and fix
|
||||
collateral drift when found.
|
||||
|
||||
## Build status (Go rewrite — deployed 2026-07-07)
|
||||
|
||||
The Oikos runtime was rewritten from Python to Go over 6 phases and is deployed
|
||||
in Docker on mac-mini. See
|
||||
[plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](../plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md)
|
||||
for the full plan. The Python codebase has been removed; all functionality runs
|
||||
in the Go binary.
|
||||
|
||||
**Phase 1 — Ontology + DB (DONE):**
|
||||
- `migrations/` (001–011): TimescaleDB hypertables, entity_status, CAGGs,
|
||||
retention policies, knowledge entities with FTS. Forward-only, idempotent.
|
||||
- `seeds/{ontology,inventory,policy,knowledge}.yaml`: DB-native bootstrap +
|
||||
DR export. Knowledge seed contains 36 documents, 6 investigations, and 12
|
||||
runbooks.
|
||||
- `blast_radius()` SQL CTE, type hierarchy, abstract types, relationship
|
||||
validation.
|
||||
- Go packages: `internal/db/`, `internal/ontology/`, `internal/domain/`,
|
||||
`internal/knowledge/`.
|
||||
|
||||
**Phase 2 — API (DONE):**
|
||||
- Single binary `cmd/oikos` with `oikos api` serving REST (:8090) + MCP
|
||||
on the same service layer. OpenAPI-first (`api/openapi.yaml`) with
|
||||
oapi-codegen + chi. RFC 9457 problem+json errors. Cursor pagination,
|
||||
If-Match/ETag optimistic concurrency, idempotency keys, SSE event stream,
|
||||
OIDC JWT + static bearer auth, audit middleware.
|
||||
- Go packages: `internal/httpapi/`, `internal/httpapi/gen/`.
|
||||
|
||||
**Phase 3 — Control loop (DONE):**
|
||||
- Scheduler (`oikos scheduler`): check_defs runner, signal dedup/flap
|
||||
suppression, entity_status. HTTP, TCP, disk, cert-expiry probes.
|
||||
- Actuator: SSH skill procedure execution with context-aware timeouts,
|
||||
circuit breaker, retry budgets, error classification.
|
||||
- Learning engine: hourly pattern extraction, Wilson confidence bounds,
|
||||
anomaly detection, skills with validated patterns.
|
||||
- Notifier: Matrix badge delivery, approval token generation (HMAC,
|
||||
single-use, hashed), DB rendezvous pattern.
|
||||
- Policy classifier: risk class determination, autonomy routing,
|
||||
blast-radius computation, kill-switch support.
|
||||
- Go packages: `internal/scheduler/`, `internal/actuator/`,
|
||||
`internal/learning/`, `internal/notifier/`, `internal/policy/`.
|
||||
|
||||
**Phase 4 — Agent / Nomos (DONE):**
|
||||
- Standalone Nomos MCP client binary (`cmd/nomos`) with gateway mode
|
||||
(:8092). Structured queries + natural-language routing to 15 MCP tools.
|
||||
Agent activity logging on every tool call. No SSH keys.
|
||||
- `nomos/` directory with config, SOUL.md, homelab-ops skill.
|
||||
- Nomos Docker service in `docker-compose.yml` (profile: full).
|
||||
- Go packages: `cmd/nomos/`, `compose/nomos/`.
|
||||
|
||||
**Phase 5 — Secrets / Infisical (DONE):**
|
||||
- `internal/secrets/`: backend abstraction (Manager) with primary
|
||||
(Infisical) and fallback (SOPS) backends. Machine identities via
|
||||
UniversalAuth. In-memory cache with TTL.
|
||||
- `oikos secret` CLI: list, migrate (SOPS → Infisical), export-sops
|
||||
(DR fallback). Infisical SDK v0.8.0.
|
||||
- Rotation runbook at `secrets/rotation.md`.
|
||||
- Docker compose: `infisical` + `redis` services (profile: infisical).
|
||||
|
||||
**Phase 6 — Deploy + cutover (DONE, pending production cutover):**
|
||||
- CI pipeline: `.gitea/workflows/ci.yml` (Gitea Actions — build, vet,
|
||||
lint, test, docker build).
|
||||
- Deploy: `scripts/deploy.sh` (git pull → docker build → compose up →
|
||||
health check), SHA-tagged images, rolling restart.
|
||||
- Caddy config: `compose/caddy/Caddyfile.oikos` (oikos/mcp/nomos →
|
||||
mac-mini mesh :8090/:8092).
|
||||
- Watchdog: `scripts/watchdog.sh` (2min cron, Matrix alert on failure).
|
||||
- Verification: `scripts/verify-phase6.sh` (14/14 checks pass).
|
||||
- Rollback: `scripts/rollback.sh` (checkout SHA + pg_restore).
|
||||
- Cutover checklist: `scripts/cutover-checklist.md`.
|
||||
|
||||
**Current deployment:**
|
||||
- **Production**: Docker stack on mac-mini (`--profile full`: postgres, api,
|
||||
scheduler, notifier, nomos). Deployed 2026-07-07 with full knowledge seed.
|
||||
The Python MCP server and secrets-issuance on apps/105 have been stopped
|
||||
(see `scripts/cutover-checklist.md`).
|
||||
|
||||
## Python-era backlog (superseded)
|
||||
|
||||
The original 30-day roadmap (Python, shipped 2026-06/07) delivered:
|
||||
context cards, change ledger, node relations, runbooks, ops scheduler,
|
||||
drift detectors, signal engine, classifier, approval engine, and the
|
||||
FastAPI+Jinja2 Oikos Console. All of these have been re-implemented in
|
||||
the Go rewrite. The backlog items below that referenced Python paths
|
||||
(`oikos/approve.py`, `oikos/drift.py`, `oikos/console/`) are now addressed
|
||||
by the Go equivalents listed above.
|
||||
|
||||
Outstanding from the Python era (not yet in Go):
|
||||
- Prometheus provisioning (see [plans/2026-07-05-oikos-prometheus-lxc.md](../plans/2026-07-05-oikos-prometheus-lxc.md))
|
||||
- CPU/NVMe temperature probing (blocked on sensor path discovery)
|
||||
- SSH-key-signed approval requests (blocked on inventory schema)
|
||||
- Multi-agent delegation (blocked on ledger identity field)
|
||||
- Restore drills on a schedule
|
||||
@@ -1,179 +0,0 @@
|
||||
# Agent developer guide
|
||||
|
||||
Instructions for AI agents working on the Oikos codebase. Read this after
|
||||
[AGENTS.md](../../AGENTS.md) and [OIKOS.md](../OIKOS.md). Human developers:
|
||||
see [CONTRIBUTING.md](../../CONTRIBUTING.md) for a human-friendly version.
|
||||
|
||||
## Codebase map
|
||||
|
||||
```
|
||||
cmd/oikos/main.go Entry point. Subcommands: api, scheduler, notifier, migrate,
|
||||
seed, export, secret, all
|
||||
cmd/nomos/main.go Nomos MCP client gateway (standalone binary, formerly Hermes)
|
||||
internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from
|
||||
internal/httpapi/gen/api.gen.go. Strict server in impl.go.
|
||||
internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.)
|
||||
internal/db/ Connection pool (pool.go), seed ingestion (seed.go), DB→YAML
|
||||
export (export.go), type hierarchy (typetree.go)
|
||||
internal/db/queries/ SQL query files → sqlc generates internal/db/sqlcgen/
|
||||
internal/scheduler/ Observe loop: probes, signals, check_defs
|
||||
internal/actuator/ SSH execution with circuit breaker + retry
|
||||
internal/learning/ Pattern extraction, anomaly detection
|
||||
internal/notifier/ Matrix notification + approval token generation
|
||||
internal/policy/ Risk classifier (read policy.yaml → classify action)
|
||||
internal/secrets/ Backend abstraction: Infisical (primary) + SOPS (fallback)
|
||||
internal/domain/ Core types: entities, approvals, executions, signals, patterns
|
||||
internal/ontology/ Type hierarchy validation, relationship checks
|
||||
internal/knowledge/ Knowledge YAML seed ingestion
|
||||
internal/config/ Config loading from env vars
|
||||
api/openapi.yaml REST API contract. Source of truth for endpoints.
|
||||
api/codegen.yaml oapi-codegen config → generates internal/httpapi/gen/
|
||||
migrations/ Forward-only SQL. Format: NNN_name.up.sql. No down migrations.
|
||||
seeds/ Bootstrap YAML. ontology.yaml, inventory.yaml, policy.yaml,
|
||||
knowledge.yaml. Regenerated from DB via oikos export.
|
||||
compose/ Dockerfiles. oikos/ (multi-stage), nomos/ (distroless).
|
||||
Caddy config at compose/caddy/Caddyfile.oikos.
|
||||
scripts/ Deploy, rollback, watchdog, verification, cutover checklist.
|
||||
nomos/ Nomos config.yaml, SOUL.md, skills.
|
||||
.agents/ Agent instruction files, domains, shared conventions, skills.
|
||||
plans/ Design documents. active/ + done/.
|
||||
docs/adr/ Architecture decision records. Numbered, prefix-sorted.
|
||||
```
|
||||
|
||||
## Development loop
|
||||
|
||||
```bash
|
||||
# Start dependencies
|
||||
make dev
|
||||
|
||||
# Generate code after API/SQL changes
|
||||
make generate
|
||||
|
||||
# Build
|
||||
make build
|
||||
|
||||
# Run tests
|
||||
make test # all unit tests
|
||||
make test-db # integration tests (needs compose Postgres)
|
||||
|
||||
# Lint
|
||||
make lint
|
||||
|
||||
# CI drift guard (run before commit)
|
||||
make generate-check
|
||||
```
|
||||
|
||||
## Adding a feature or phase
|
||||
|
||||
Oikos features follow a phase model (read [OIKOS.md](../OIKOS.md) for the
|
||||
current phase status). To add a new capability:
|
||||
|
||||
1. **ADR first.** Write an architecture decision record in `docs/adr/` with
|
||||
the next sequence number. Document the decision, context, alternatives
|
||||
considered, and consequences.
|
||||
2. **Plan.** If the change is non-trivial, create a plan in `plans/` following
|
||||
the template in [page-templates.md](../shared/page-templates.md).
|
||||
3. **Schema.** If the feature needs new DB tables, write a forward-only
|
||||
migration in `migrations/`. Use `IF NOT EXISTS` for idempotency.
|
||||
4. **API.** If the feature exposes endpoints, define them in
|
||||
`api/openapi.yaml` first, then run `make generate`, then implement.
|
||||
5. **Domain.** Add types to `internal/domain/` before adding logic.
|
||||
6. **Tests.** Write tests alongside implementation. Integration tests go in
|
||||
`*_test.go` in the relevant package, using the compose Postgres.
|
||||
7. **Policy.** If the feature introduces new mutation types, update
|
||||
`seeds/policy.yaml` and the classifier in `internal/policy/`.
|
||||
8. **Run `make generate-check`** before commit to ensure generated code is
|
||||
current.
|
||||
|
||||
## SQL conventions
|
||||
|
||||
- Queries live in `internal/db/queries/*.sql` with `-- name: FuncName :exec`
|
||||
annotations for sqlc
|
||||
- Use `pgx/v5` driver. UUIDs use `pgtype.UUID`, timestamps use `time.Time`
|
||||
- CTEs for graph traversals (blast radius, dependency chains)
|
||||
- CAGGs and retention policies for TimescaleDB hypertables
|
||||
- FTS via `tsvector` + `tsquery` for knowledge search (migration 011)
|
||||
|
||||
## OpenAPI codegen
|
||||
|
||||
- Config: `api/codegen.yaml`. Uses `oapi-codegen/v2` with Chi server template
|
||||
- Generated output: `internal/httpapi/gen/api.gen.go` — never hand-edit
|
||||
- Strict server interface: `api.gen.go` generates the `StrictServerInterface`;
|
||||
implement it in `internal/httpapi/impl.go`
|
||||
- Problem+JSON errors via `internal/httpapi/problem.go` — RFC 9457 format
|
||||
- Cursor pagination, If-Match/ETag, idempotency keys, SSE streaming
|
||||
|
||||
## Testing philosophy
|
||||
|
||||
- **Race detector always on.** `make test` runs `go test -race -cover ./...`
|
||||
- **Integration tests** use the compose Postgres. Run with `make test-db`.
|
||||
Each test creates + tears down its own schema namespace.
|
||||
- **Coverage gates** in CI: policy + learning ≥ 80%, others ≥ 60%
|
||||
- Tests use `testing.T` directly, no assertion library
|
||||
- Table-driven tests for validation and classification logic
|
||||
|
||||
## Migration rules
|
||||
|
||||
- Forward-only. No down migrations (ADR 0008)
|
||||
- Idempotent: use `IF NOT EXISTS`, `DO $$ BEGIN ... END $$` blocks
|
||||
- Sequence numbers are sequential integers (001, 002, ...)
|
||||
- Each migration file is `NNN_name.up.sql`
|
||||
- Migrations are embedded in the binary via `migrations/embed.go`
|
||||
|
||||
## Seed files
|
||||
|
||||
- `seeds/ontology.yaml` — entity types, relationship types, lifecycles
|
||||
(validated against schema in `internal/ontology/`)
|
||||
- `seeds/inventory.yaml` — hosts, services, entities (the topology)
|
||||
- `seeds/policy.yaml` — risk classes, approval rules, autonomy settings
|
||||
- `seeds/knowledge.yaml` — documents, investigations, runbooks (DB is source
|
||||
of truth; this file is the DR export)
|
||||
- After DB changes via the API, run `make export` to regenerate seeds
|
||||
|
||||
## Secrets handling
|
||||
|
||||
- No secrets in code, config, or commits
|
||||
- Dev secrets in `.env` (gitignored)
|
||||
- Primary: Infisical (`internal/secrets/infisical.go`)
|
||||
- Fallback: SOPS + age (`internal/secrets/sops.go`)
|
||||
- Backend interface: `internal/secrets/backend.go`
|
||||
- Machine identities via Infisical UniversalAuth
|
||||
- In-memory cache with TTL for performance
|
||||
|
||||
## Staging and deployment
|
||||
|
||||
- CI pipeline: `.gitea/workflows/ci.yml` — lint, vet, vulncheck, test, docker build
|
||||
- Deploy: `scripts/deploy.sh` — git pull → docker build → compose up → health check
|
||||
- Watchdog: `scripts/watchdog.sh` — 2-minute cron, Matrix alert on failure
|
||||
- Rollback: `scripts/rollback.sh` — checkout SHA + pg_restore
|
||||
- Cutover checklist: `scripts/cutover-checklist.md`
|
||||
|
||||
## Writing conventions
|
||||
|
||||
Apply [writing-style.md](../shared/writing-style.md) for all committed prose.
|
||||
Terse, reference-style, no marketing vocabulary. Code comments explain intent
|
||||
and trade-offs, not mechanics.
|
||||
|
||||
Apply [caveman.md](../shared/caveman.md) for agent communication. The caveman
|
||||
standard applies to agent *chat responses*, not committed documentation.
|
||||
|
||||
## Skills
|
||||
|
||||
Agent skills live under `.agents/skills/<name>/SKILL.md`. Each skill has a
|
||||
frontmatter description that tools match against tasks. To add a skill:
|
||||
|
||||
1. Create `.agents/skills/<name>/SKILL.md`
|
||||
2. Include frontmatter with description field
|
||||
3. Document the procedure following the runbook template
|
||||
4. Reference relevant files, commands, and policy classes
|
||||
|
||||
Skills that require code (e.g. linting) may include companion scripts in the
|
||||
same directory.
|
||||
|
||||
## When in doubt
|
||||
|
||||
- Query MCP tools first (search_knowledge, get_entity)
|
||||
- Read the relevant ADR in `docs/adr/`
|
||||
- Grep the codebase: `rg <symbol> internal/`
|
||||
- Check `plans/` for in-progress work that may conflict
|
||||
- Classify any new mutation against `seeds/policy.yaml` before suggesting it
|
||||
@@ -1,48 +0,0 @@
|
||||
# Knowledge domain — schema
|
||||
|
||||
The knowledge domain is the durable, authoritative current-state documentation of the homelab: one
|
||||
page per node and per cross-cutting system, synthesized from live state and evidence. It answers
|
||||
"what exists and how does it work right now."
|
||||
|
||||
It follows the [LLM Wiki layer model](../../shared/llm-wiki.md) and the
|
||||
[writing-style](../../shared/writing-style.md) and [page-templates](../../shared/page-templates.md)
|
||||
rules.
|
||||
|
||||
## The narrative / substrate split
|
||||
|
||||
The knowledge wiki is **narrative**. It sits alongside a **machine-readable substrate** that it
|
||||
describes but never contains. The split is load-bearing: several programs read the substrate at
|
||||
fixed paths, so the wiki reorganization never moves it.
|
||||
|
||||
| Layer | Location | Consumed by |
|
||||
|-------|----------|-------------|
|
||||
| Substrate — source of truth | `inventory.yaml` (root) | MCP server, `homelab` CLI, `oikos/` scheduler/drift/relations/gen-topology |
|
||||
| Substrate — generated host records | `inventory.yaml` (root) | Go `internal/mcp/` server, `bin/homelab`; the single source of truth |
|
||||
| Substrate — kernel + context cards | `oikos/` (code, `oikos/cards/`, `oikos/state.json`) | MCP `explain`, scheduler |
|
||||
| Narrative — synthesized wiki | `archive/knowledge/{hosts,containers,vms,infrastructure}/` | humans, agents via MCP `get_page` / `search_docs` |
|
||||
| Evidence — immutable sources | `knowledge/sources/` (references + investigations) | synthesis into wiki pages |
|
||||
|
||||
## Wiki pages
|
||||
|
||||
- **Node pages** (`archive/knowledge/containers/<id>-<name>.md`, `.../vms/<id>-<name>.md`,
|
||||
`.../hosts/<name>.md`) follow the container/host template in
|
||||
[page-templates.md](../../shared/page-templates.md): opening definition, `## At a glance`,
|
||||
`## Role`, service/port map, storage, auto-deploy, `## Related`, `## Changelog`.
|
||||
- **Cross-cutting pages** (`archive/knowledge/infrastructure/<topic>.md`) follow the cross-cutting
|
||||
template: `## Why`, `## Components`, `## How to apply`, `## Gotchas`, `## Related`, `## Changelog`.
|
||||
- Each `inventory.yaml` host entry carries a `doc_page:` field pointing at its narrative page.
|
||||
Changing where a page lives means updating that field (read by `bin/homelab`).
|
||||
|
||||
## The two logs
|
||||
|
||||
- The per-page **`## Changelog`** records infrastructure changes and is machine-parsed
|
||||
(`get_changelog`, the Oikos ledger). Keep the `### YYYY-MM-DD — title` shape.
|
||||
- **`knowledge/log.md`** is append-only and records *documentation-maintenance* operations only
|
||||
(restructures, source ingests, lint sweeps): `## [YYYY-MM-DD] <op> | <summary>`. It never
|
||||
duplicates the Oikos change ledger (`oikos/ledger.py`).
|
||||
|
||||
## Same-session update rule
|
||||
|
||||
A change to a node updates every page that references it in the same session — the node page, the
|
||||
section `README.md` table, the root `README.md`, the Caddy/DNS/ingress pages, the host page, and
|
||||
`inventory.yaml`. See [page-templates.md](../../shared/page-templates.md#same-session-update-rule).
|
||||
@@ -1,55 +0,0 @@
|
||||
# Operations domain — schema
|
||||
|
||||
The operations domain holds the procedural and time-stamped documentation: runbooks (repeatable
|
||||
procedures), investigations (incident evidence), and plans (design docs for non-trivial work). It
|
||||
follows [writing-style](../../shared/writing-style.md); runbooks and plans use the imperative voice
|
||||
exception.
|
||||
|
||||
Where each kind lives: runbooks are skills under [`.agents/skills/`](../../skills/); operator
|
||||
reference (command cheatsheet, enrollment, Hermes agent) lives in
|
||||
[`.agents/operations/`](../../operations/); investigations are sources under
|
||||
`knowledge/sources/investigations/`; plans stay in the repo-root `plans/` folder (below).
|
||||
|
||||
## Plans always live in `plans/`
|
||||
|
||||
**Any plan or design doc for the Homelab is written into the repo `plans/` folder as
|
||||
`plans/YYYY-MM-DD-slug.md` — never a scratch path, an agent-private plan location, or a chat
|
||||
message.** An agent drafting a plan:
|
||||
|
||||
1. Writes the file under `plans/` using the plan template in [page-templates.md](../../shared/page-templates.md).
|
||||
2. Lists it in `plans/index.md`.
|
||||
3. On completion, moves it to `plans/done/` and updates the index status.
|
||||
|
||||
This is the single source for homelab design intent; keeping it in-repo means the plan is
|
||||
versioned, reviewable, and reachable by MCP `get_page`/`search_docs` like any other doc.
|
||||
|
||||
## Runbooks
|
||||
|
||||
Repeatable procedures are skills — one folder per skill at `.agents/skills/<name>/SKILL.md`, with
|
||||
YAML front-matter that the Oikos policy and lifecycle machinery reads:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <name>
|
||||
risk_class: read_only | reversible_low | config_mutation | destructive
|
||||
inputs: [<param>, ...]
|
||||
verification: "<shell expression that proves success>"
|
||||
docs_update_checklist: [<doc artifacts to update>]
|
||||
transition: "<from> -> <to>" # only for lifecycle runbooks
|
||||
---
|
||||
```
|
||||
|
||||
`risk_class` values and the lifecycle `transition` states must match
|
||||
[`seeds/policy.yaml`](../../../seeds/policy.yaml) and [`seeds/ontology.yaml`](../../../seeds/ontology.yaml).
|
||||
|
||||
## Investigations
|
||||
|
||||
Incident records live in `knowledge/sources/investigations/YYYY-MM-DD-slug.md` and are **evidence sources** — written
|
||||
once at incident time, then linked from the changelogs of the nodes they implicate. Sections:
|
||||
`## Summary`, `## Timeline`, `## Root cause`, `## Mitigations applied`, `## Open questions`. Resolved
|
||||
incidents move to `knowledge/sources/investigations/archive/`.
|
||||
|
||||
## The operations log
|
||||
|
||||
`plans/log.md` and `knowledge/log.md` are append-only records of documentation operations on
|
||||
those areas (`## [YYYY-MM-DD] <op> | <summary>`), distinct from the Oikos change ledger.
|
||||
@@ -1,91 +0,0 @@
|
||||
# Operations cheatsheet
|
||||
|
||||
Run from the [hubris host](../../archive/knowledge/hosts/hubris.md) as root. When working from `/root` on Linux you're already on hubris — don't `ssh hubris` / `ping hubris`.
|
||||
|
||||
## Proxmox CLI
|
||||
|
||||
| Command | Use |
|
||||
| --- | --- |
|
||||
| `pct list` / `qm list` | List LXC containers / VMs |
|
||||
| `pct config <id>` / `qm config <id>` | Container / VM config |
|
||||
| `pct exec <id> -- <cmd>` | Run command inside an LXC without entering it (no initgroups — see [media permissions](../../archive/knowledge/infrastructure/media-permissions.md)) |
|
||||
| `pct enter <id>` | Shell into a container |
|
||||
| `pct start <id>` / `pct stop <id>` | Boot / halt a container |
|
||||
| `pvesm status` | Storage pools status |
|
||||
| `pvesh get /nodes --output-format json` | Node summary as JSON |
|
||||
| `pvesh get /nodes/hubris/lxc/<id>/status/current` | Live container status |
|
||||
| `pvesh get /cluster/resources --type vm --output-format json` | Bulk per-LXC CPU/mem/disk (used by the `homelab-health-watchdog` Nomos cron — see [monitoring](../../archive/knowledge/infrastructure/monitoring.md); the old `claudio-monitor` this once fed is deprecated) |
|
||||
| `pveversion` | PVE version |
|
||||
| `journalctl -u pve-cluster -n 100` | PVE service logs |
|
||||
|
||||
## Storage
|
||||
|
||||
- Shared mount: `/mnt/library` (ext4 on lvmthin `library`).
|
||||
- Bind into a container: `pct set <id> -mp<N> /mnt/library/<sub>,mp=/data`
|
||||
- For the standard whole-tree mount: `pct set <id> -mp0 /mnt/library,mp=/mnt/library`. See [media permissions](../../archive/knowledge/infrastructure/media-permissions.md) for the GID-10000 onboarding recipe.
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
- Caddyfile: `/etc/caddy/Caddyfile` on [LXC 121](../../archive/knowledge/containers/121-caddy.md).
|
||||
- **CRITICAL:** This file is tracked in `dtoro/caddy-conf` (https://git.hubris.network/dtoro/caddy-conf). Never edit it directly on the LXC — commit + push to the repo instead. Caddy auto-deploys on push (see [auto-deploy](../../archive/knowledge/infrastructure/auto-deploy.md)). If you edit directly, the change will be lost on the next pull and agents won't know about it.
|
||||
- Hot reload: `pct exec 121 -- systemctl reload caddy`.
|
||||
- Validate: `pct exec 121 -- caddy validate --config /etc/caddy/Caddyfile`.
|
||||
- Git workflow shortcut: `pct exec 121 -- "cd /etc/caddy && git add Caddyfile && git commit -m '...' && git push"`.
|
||||
|
||||
## DNS
|
||||
|
||||
- Split-horizon authority: [Technitium DNS](https://technitium.com) on [dns (107)](../../archive/knowledge/containers/107-dns.md) at `192.168.8.2:53`. Web UI at `http://192.168.8.2`. (Formerly dnsmasq on the now-destroyed LXC 124 — decommissioned 2026-06-04.)
|
||||
- Add/edit records in the Technitium UI; the NetBird managed zone sync (`scripts/dns-sync.py` cron on 107) picks changes up within ~10 minutes.
|
||||
- Verify: `dig @192.168.8.2 +short <host>.hubris.network`.
|
||||
- See [DNS](../../archive/knowledge/infrastructure/dns.md).
|
||||
|
||||
## Web access
|
||||
|
||||
- `https://proxmox.hubris.network` or `https://192.168.8.77:8006` — Proxmox UI
|
||||
|
||||
## Telemetry quick checks
|
||||
|
||||
- `ras-mc-ctl --summary` — summary of any RAS events (memory / PCIe AER / thermal) since boot
|
||||
- `ras-mc-ctl --errors` — full event log
|
||||
- `cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference` — should be `balance_power`
|
||||
- `cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor` — should be `powersave`
|
||||
- `ls /sys/fs/pstore/ /var/lib/systemd/pstore/` — panic traces from a previous crash (empty for pure hardware hangs — see [investigation](../../archive/knowledge/investigations/archive/2026-04-21-hubris-crash-loop.md))
|
||||
|
||||
## Fleet apt operations
|
||||
|
||||
Two `homelab` subcommands wrap the common patterns; both fan out to hubris + every LXC.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `homelab apt-audit [--target HOST]` | Per-host table: dpkg-interrupted state, holds, upgradable count, non-apt binaries in system paths, DNS health. Exits nonzero if any host has dpkg-interrupted state. |
|
||||
| `homelab apt-upgrade --target HOST` | Launch `apt update && apt upgrade` inside a transient `systemd-run --collect` unit on the target. Survives ssh teardown. Apt configured with `Acquire::Retries=3` + `ForceIPv4=true`. |
|
||||
| `homelab apt-upgrade --all` | Same, fanned out across the standard targets. |
|
||||
| `homelab apt-upgrade ... --status` | Show running unit + tail `/var/log/homelab-apt-upgrade.log` on each target. |
|
||||
| `homelab apt-upgrade ... --safe` | Take a pre-upgrade snapshot per LXC first (`pct snapshot` → `vzdump` fallback for bind-mounted LXCs). Refuses if any snapshot fails unless `--force`. |
|
||||
| `homelab apt-upgrade ... --force` | Skip both the dpkg-audit gate and snapshot-failure refusal. |
|
||||
|
||||
PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm.
|
||||
|
||||
## Oikos (agent OS layer)
|
||||
|
||||
See [OIKOS.md](../OIKOS.md) for the operating model. Quick reference:
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `homelab service <name> explain\|health\|docs\|log\|actions\|history` | Service Console v0 — context card, cached health (`--live` to force a probe), docs, logs, safe actions + risk class, ledger history |
|
||||
| `homelab node <name> relations` | Ontology blast-radius query: what this host/service impacts, is affected by, and its full transitive blast radius |
|
||||
| `homelab change preflight <service>` | Dry-run report before mutating: risk class, current health, config repo, verification command |
|
||||
| `homelab decide <action> <entity>` | Decision classifier: risk × blast radius × confidence → auto-act or escalate |
|
||||
| `homelab signal list\|raise\|ack\|resolve\|mute` | The attention layer — pending updates, thresholds, drift, anything needing attention |
|
||||
| `homelab approval request\|list\|reply\|check` | Escalate-route grants (Matrix-delivered via Nomos, or the Oikos Console's `/approvals` page) |
|
||||
| `homelab restart <service> [--approval-id <id>]` | `--approval-id` is required whenever the service's risk class needs approval (e.g. `caddy`, `dns`) — refuses mechanically without a valid grant |
|
||||
|
||||
Oikos Console (read-mostly dashboard): `oikos.hubris.network` once deployed — see [oikos/console/deploy/README.md](../../archive/oikos-cards/).
|
||||
|
||||
## Related
|
||||
- [Hubris host](../../archive/knowledge/hosts/hubris.md)
|
||||
- [Containers index](../../archive/knowledge/containers/index.md)
|
||||
- [DNS](../../archive/knowledge/infrastructure/dns.md)
|
||||
- [Monitoring](../../archive/knowledge/infrastructure/monitoring.md)
|
||||
- [Auto-deploy](../../archive/knowledge/infrastructure/auto-deploy.md)
|
||||
- [Runbook: dpkg-interrupted recovery](../skills/runbook-dpkg-interrupted/SKILL.md) — what to do when apt got killed mid-transaction
|
||||
@@ -1,33 +0,0 @@
|
||||
# CAVEMAN.md — communication mode for homelab agents
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Levels
|
||||
|
||||
- **lite** — no filler/hedging. Keep articles + full sentences. Professional but tight.
|
||||
- **full** (default) — drop articles, fragments OK, short synonyms. Classic caveman.
|
||||
- **ultra** — abbreviate prose words (DB/auth/config/req/res), strip conjunctions, arrows (X → Y). Code symbols/API names/errors: never abbreviate.
|
||||
|
||||
Switch: `/caveman lite|full|ultra`. Stop: "normal mode".
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman for: security warnings, irreversible actions, multi-step sequences where fragments risk misread, user confused/repeating. Resume after clear part done.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
|
||||
---
|
||||
|
||||
Source: https://github.com/JuliusBrussee/caveman
|
||||
Copy to `~/.nomos/skills/` for Nomos agent, or `~/.claude/projects/<name>/SKILL.md` for Claude Code.
|
||||
@@ -1,41 +0,0 @@
|
||||
# LLM Wiki — the documentation contract
|
||||
|
||||
How the narrative documentation in this repo is organized. The pattern is borrowed from the
|
||||
`sources / wiki / index / log` model: a durable synthesized layer (`archive/knowledge/`) built on top
|
||||
of immutable evidence (`knowledge/sources/`, incident records), with pure-listing indexes and an
|
||||
append-only operations log.
|
||||
|
||||
This contract governs the **narrative layer only**. The machine-readable substrate — `inventory.yaml`,
|
||||
`secrets/`, `scripts/`, `bin/` — is not part of the wiki and never
|
||||
moves under it. See [the knowledge schema](../domains/knowledge/schema.md) for the split.
|
||||
|
||||
## Layers
|
||||
|
||||
- **Sources** are immutable raw material: incident records (`knowledge/sources/investigations/`), external reference
|
||||
docs (`knowledge/sources/references/`), and the live system itself (`pct config`, `docker inspect`).
|
||||
Read them; do not rewrite them into other sources.
|
||||
- **Wiki** (`archive/knowledge/`) is the synthesized, authoritative current-state layer: one page per
|
||||
node (`containers/`, `vms/`, host narratives) and per cross-cutting system (`infrastructure/`). A
|
||||
reader understands the topic from the wiki page without reading the sources.
|
||||
- **Index** (`index.md` / folder `README.md`) is a pure listing — every page in scope with a
|
||||
one-line summary, and nothing else. Anything the section wants to say up front goes into a page
|
||||
the index lists, not into the index.
|
||||
- **Log** (`log.md`) is append-only, recording *doc-maintenance operations* (restructures, source
|
||||
ingests, lint sweeps) in single-line format: `## [YYYY-MM-DD] <op> | <summary>`.
|
||||
|
||||
## Two logs, kept distinct
|
||||
|
||||
- **`## Changelog`** on each node/topic page records *infrastructure* changes to that node. It is
|
||||
machine-parsed (`get_changelog`, the Oikos ledger) — keep the `### YYYY-MM-DD — title` shape.
|
||||
- **`log.md`** per area records *documentation* operations only. It never duplicates the Oikos
|
||||
change ledger (`oikos/ledger.py`), which stays authoritative for infra changes with
|
||||
who/what/risk/approval/verification.
|
||||
|
||||
## Rules
|
||||
|
||||
- Wiki pages stay short and focused. A page past ~300 lines splits.
|
||||
- Pages stay flat under `wiki/<section>/` until there are enough to warrant a sub-group.
|
||||
- Every page follows [writing-style.md](writing-style.md).
|
||||
- Plans and design docs always live in the repo `plans/` folder (`plans/YYYY-MM-DD-slug.md`),
|
||||
listed in `plans/index.md`, moved to `plans/done/` on completion — never a scratch path or a chat
|
||||
message. See [the operations schema](../domains/operations/schema.md).
|
||||
@@ -1,174 +0,0 @@
|
||||
# Page templates for the Homelab Wiki
|
||||
|
||||
The structural templates for each page type. Prose voice, vocabulary, and cross-reference rules live
|
||||
in [writing-style.md](writing-style.md); the layer model (sources / wiki / index / log) lives in
|
||||
[llm-wiki.md](llm-wiki.md).
|
||||
|
||||
## File naming
|
||||
|
||||
**Foundational / entry-point files:** ALL-CAPS
|
||||
|
||||
- **Root level:** `AGENTS.md`, `README.md` — discovery paths for agents and humans.
|
||||
- **Agent instruction** (under `.agents/`): `OIKOS.md`, `HERMES.md` — foundational docs agents read before acting.
|
||||
- **Reference docs:** `GLOSSARY.md` — lookup reference (like classic repo conventions: LICENSE, CHANGELOG, GLOSSARY).
|
||||
|
||||
**Content / narrative pages:** lowercase-with-dashes, date-prefixed as needed
|
||||
|
||||
- **Container pages:** `<id>-<name>.md` (e.g. `101-jellyfin.md`, `132-rclone.md`). The `<id>` is the LXC/VM ordinal from `inventory.yaml`.
|
||||
- **Infrastructure / cross-cutting pages:** `<topic>.md` (e.g. `dns.md`, `auto-deploy.md`, `mesh.md`). Describes a system, not a specific node.
|
||||
- **Plans / investigations:** `YYYY-MM-DD-<slug>.md` (e.g. `2026-07-05-oikos-prometheus-lxc.md`). Date-sorted; slug is lowercase.
|
||||
- **Section indices:** `README.md` (lowercase, conventional). Prefer in folders; `index.md` only if both intro prose and listing coexist.
|
||||
|
||||
**Skills / runbooks:** special case
|
||||
|
||||
- **Folder structure:** `<name>/SKILL.md` where `<name>` is lowercase-with-dashes (e.g. `client-enrollment/SKILL.md`).
|
||||
- **The filename SKILL.md is always uppercase** — it acts as a signpost so tools and humans instantly recognize it as a skill.
|
||||
|
||||
**General rules:** All paths use lowercase letters, numbers, and hyphens (no underscores). Uppercase is reserved for foundational docs (entry points + instruction) and filenames that signify document type (SKILL.md, GLOSSARY.md, etc.).
|
||||
|
||||
## Voice
|
||||
|
||||
Concise, technical, sysadmin-to-sysadmin. No marketing prose, no exclamation marks. Full rules in
|
||||
[writing-style.md](writing-style.md).
|
||||
|
||||
## Page templates
|
||||
|
||||
### Container page (`containers/<id>-<name>.md`)
|
||||
|
||||
```markdown
|
||||
# <id> — `<name>`
|
||||
|
||||
One-sentence purpose.
|
||||
|
||||
## At a glance
|
||||
- **Hostname:** `<name>`
|
||||
- **IP:** `192.168.8.x`
|
||||
- **Privilege:** privileged | unprivileged
|
||||
- **Resources:** N cores / M GiB RAM / D GiB rootfs
|
||||
- **Mounts:** `/mnt/library` ↔ `/mnt/library` (if any)
|
||||
- **Public hostname:** `<sub>.hubris.network` (if proxied)
|
||||
|
||||
## Role
|
||||
What it does, what it talks to.
|
||||
|
||||
## Service / port map
|
||||
| Service | Listen | Notes |
|
||||
|
||||
## Storage / config paths
|
||||
|
||||
## Auto-deploy
|
||||
(if any) — link to [auto-deploy](../infrastructure/auto-deploy.md)
|
||||
|
||||
## Related
|
||||
- [Caddy](121-caddy.md) (if proxied)
|
||||
- [DNS](../infrastructure/dns.md) (if has subdomain)
|
||||
- [Authentik](124-authentik.md) (if SSO)
|
||||
- ...
|
||||
|
||||
## Changelog
|
||||
### YYYY-MM-DD — short title
|
||||
What changed, why, link to investigation if any.
|
||||
```
|
||||
|
||||
### Cross-cutting page (`infrastructure/<topic>.md`)
|
||||
|
||||
```markdown
|
||||
# <Topic>
|
||||
|
||||
One-sentence summary.
|
||||
|
||||
## Why
|
||||
Design rationale — what it replaces, what it solves.
|
||||
|
||||
## Components
|
||||
Where it runs, what files matter.
|
||||
|
||||
## How to apply / use
|
||||
Recipes.
|
||||
|
||||
## Gotchas
|
||||
|
||||
## Related
|
||||
Links to nodes that host or depend on this.
|
||||
|
||||
## Changelog
|
||||
```
|
||||
|
||||
### Plan (`plans/YYYY-MM-DD-slug.md`)
|
||||
|
||||
```markdown
|
||||
# YYYY-MM-DD — <title>
|
||||
|
||||
## Goal
|
||||
What this change achieves and why.
|
||||
|
||||
## Current topology / state
|
||||
Diagram or description of what exists now.
|
||||
|
||||
## Target topology / state
|
||||
What it looks like after.
|
||||
|
||||
## Pre-flight checklist
|
||||
|
||||
## Step-by-step procedure
|
||||
|
||||
## Verification
|
||||
|
||||
## Post-migration
|
||||
Changelog entries to write, index status to update.
|
||||
```
|
||||
|
||||
### Investigation (`knowledge/sources/investigations/YYYY-MM-DD-slug.md`)
|
||||
|
||||
```markdown
|
||||
# YYYY-MM-DD — <title>
|
||||
|
||||
## Summary
|
||||
1-3 sentences.
|
||||
|
||||
## Timeline
|
||||
|
||||
## Root cause
|
||||
|
||||
## Mitigations applied
|
||||
|
||||
## Open questions
|
||||
```
|
||||
|
||||
## Linking discipline
|
||||
|
||||
- Every container page links to every cross-cutting page it participates in.
|
||||
- Every cross-cutting page lists the nodes that participate.
|
||||
- Every investigation links to the nodes it implicates *and* gets back-linked from each node's changelog.
|
||||
- Every plan links to the infrastructure pages it affects. When done, update the plan's status in `plans/index.md` and write changelog entries on affected node pages.
|
||||
|
||||
## Changelog hygiene
|
||||
|
||||
- Reverse-chronological (newest first).
|
||||
- One entry per discrete change, even if you make several in one day.
|
||||
- If a change spans nodes, repeat the entry on each affected page (different perspective is fine).
|
||||
- Don't rewrite history — entries are append-only. Mistakes get a follow-up entry that supersedes them.
|
||||
|
||||
## Same-session update rule
|
||||
|
||||
When you make a change to a node — migrate an LXC, update an IP, change a
|
||||
mount, deploy a new service — **update every relevant doc page in the same
|
||||
session.** A change that touches a container page must also update:
|
||||
|
||||
- The `containers/index.md` table (IPs, host, mounts, status)
|
||||
- The `README.md` table (if the change affects listed columns)
|
||||
- The Caddy page site list (if the change affects `*.hubris.network` routing)
|
||||
- The DNS / ingress infrastructure pages (if the change affects routing)
|
||||
- The `hosts/{hubris,strong}.md` host page (if container count changes)
|
||||
- The `inventory.yaml` host entry (single source of truth)
|
||||
- The `infrastructure/topology.md` (generated from inventory, but regen if needed)
|
||||
|
||||
The pattern of updating only one page and leaving stale references on others
|
||||
is a bug. If you're doing a multi-step migration, document the intermediate
|
||||
state with a changelog entry that says "pending — will finalize after Phase
|
||||
N."
|
||||
|
||||
This rule is why Phase 2 of the strong migration (2026-07-05) caused
|
||||
widespread stale data: individual container pages were updated in the
|
||||
changelog but never had their At-a-glance sections, IPs, mount paths, or
|
||||
host attribution updated. Don't repeat that.
|
||||
@@ -1,75 +0,0 @@
|
||||
# Writing Style
|
||||
|
||||
Write like a technical reference, not a marketing page. Every sentence conveys new information.
|
||||
These rules govern **committed documentation** — wiki pages, READMEs, schemas, skills, `AGENTS.md`,
|
||||
plans, investigations, and code comments. They are separate from [caveman.md](caveman.md), which
|
||||
governs an agent's *chat responses*; the two do not conflict.
|
||||
|
||||
New or rewritten pages follow these patterns from day one. Existing pages get updated the next time
|
||||
they are touched.
|
||||
|
||||
## Vocabulary — never use these
|
||||
|
||||
- Significance puffers: "pivotal", "crucial", "vital", "groundbreaking", "transformative", "testament", "paramount", "invaluable".
|
||||
- Analytical verbs: "delve", "leverage", "utilize", "facilitate", "foster", "showcase", "underscore", "streamline", "harness".
|
||||
- Poetic nouns: "tapestry", "landscape" (figurative), "realm", "paradigm", "ecosystem" (figurative), "journey" (figurative), "nexus", "cornerstone".
|
||||
- Promotional adjectives: "robust", "seamless", "innovative", "cutting-edge", "meticulous", "holistic", "comprehensive".
|
||||
- Opening crutches: "In today's world", "In the ever-evolving landscape of", "It's worth noting that", "It is important to note that".
|
||||
|
||||
Use short, common words: "use" not "utilize", "help" not "facilitate", "show" not "demonstrate".
|
||||
|
||||
## Voice
|
||||
|
||||
Describe what systems do and how they work.
|
||||
|
||||
- **Reference prose** (node pages, cross-cutting infrastructure descriptions, `## Role`, `## Why`,
|
||||
`At a glance`) is third-person: state facts about the system, not instructions to a reader.
|
||||
- **Recipes, runbooks, and skills** are the exception: second-person imperative is allowed and
|
||||
preferred where it makes a procedure clearer ("Edit the Caddyfile, commit + push", "Verify with
|
||||
`dig +short`"). This matches how the operator actually works. The vocabulary, structure, and
|
||||
cross-reference rules below still apply.
|
||||
|
||||
## Page shape
|
||||
|
||||
Every doc-level page follows the same shape so a reader scans it in one pass.
|
||||
|
||||
1. **One H1 = the page title.** Node pages use `# <id> — \`<name>\``; topic pages use `# <Topic>`.
|
||||
2. **Opening definition.** First paragraph, 1–3 sentences, says what the thing is. No motivation, no marketing, no setup.
|
||||
3. **Body sections** in the natural order for the topic. Reuse the section templates in [page-templates.md](page-templates.md).
|
||||
4. **`## Changelog`** at the bottom of every node/topic page — reverse-chronological, append-only. This section is machine-parsed (Go MCP `get_changelog` in `internal/mcp/server.go`); keep the `### YYYY-MM-DD — title` shape.
|
||||
5. **Related links** only at the bottom, only when a reference cannot be woven inline.
|
||||
|
||||
## Section indexes (folder READMEs)
|
||||
|
||||
A folder's `README.md` opens with a 1–3 sentence prose intro that says what the section covers, then
|
||||
a single navigation table — `| Document | What it covers |` — and nothing else. No stale counts, no
|
||||
duplicated prose, no narrative between the intro and the table.
|
||||
|
||||
## Structure rules
|
||||
|
||||
- Make every sentence information-dense. Cut filler, qualifiers, and setup phrases. Lead with the concrete fact or action, not why it matters.
|
||||
- No participial tack-ons (", highlighting the importance of…"). If the clause adds information, make it a separate sentence.
|
||||
- **No meta-commentary about the content itself.** Do not narrate the page's own structure or linking strategy.
|
||||
- Prefer **tables** for enumerable items with internal structure (service/port maps, field lists, status grids). Reserve bullets for short non-structured lists.
|
||||
- Use the **bold-leading-phrase pattern** for structured points: `**Read-only by construction.** The MCP server never mutates state.` — a bold noun phrase, a period, then the explanation.
|
||||
- When enumerating across services or nodes, give each its own `###` sub-section or a table row, not one run-on paragraph.
|
||||
- Use backticks for code, paths, hostnames, and file names (`inventory.yaml`, `192.168.8.77`, `pct config`); italics for first-mention terminology.
|
||||
- Use `>` blockquotes for caveats and gaps that interrupt the main flow: `> **Outstanding gap.** DNS-vs-inventory drift check not yet wired.` One thought per blockquote.
|
||||
|
||||
## Diagrams
|
||||
|
||||
- Mermaid is the default for topology and flow diagrams. `infrastructure/topology.md` is generated by `oikos/gen-topology.py` — do not hand-edit it. (Go DB-native topology generation planned.)
|
||||
- ASCII box diagrams are fine for small shape diagrams; keep them to one screen.
|
||||
|
||||
## Sourcing and cross-references
|
||||
|
||||
- **Factual discipline.** Every claim is grounded in a cited source, an adjacent linked page, or a directly observable fact (`pct config`, `docker inspect`, running config). Do not write sentences that sound sourced but are inference. When docs disagree with live state, fix the doc and note it in the changelog.
|
||||
- **One-sided cross-references.** When two pages relate, the link lives in the page where the connection makes organizational sense. Do not add a back-pointer unless that direction also carries content the reader needs.
|
||||
- **Cross-references are content, not catalog.** Inline links arise from the surrounding prose; the linked page must be needed to understand the current sentence. A bottom-of-page "Related" list is the fallback, not the default.
|
||||
- Pages link with standard relative markdown links (e.g. a container page links to `../infrastructure/dns.md`), forming a navigable graph. Orphans are a bug.
|
||||
|
||||
## Code comments and commit/PR prose
|
||||
|
||||
- Comments explain intent, trade-offs, or constraints the code cannot convey. No diff narration, no type restatement, no section-divider comments.
|
||||
- Commit messages and PR descriptions are problem → change → risk → verification, not a file-by-file diff restatement.
|
||||
- The banned vocabulary applies the same way in comments and commit messages.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: client-enrollment
|
||||
risk_class: config_mutation
|
||||
inputs: [hostname, kind, role]
|
||||
verification: "homelab doctor (on the new client)"
|
||||
docs_update_checklist: [hosts_narrative_page_if_lxc_or_vm]
|
||||
---
|
||||
|
||||
# Client enrollment
|
||||
|
||||
Goal: bring a new host (workstation, LXC, VM) into inventory and the
|
||||
secrets model, with mesh membership only where it's actually needed.
|
||||
This wraps the existing `homelab client add` flow — see
|
||||
[operations/agent-enrollment.md](../../operations/agent-enrollment.md) for
|
||||
the full walkthrough; this runbook is the risk/lifecycle framing.
|
||||
|
||||
1. On any enrolled client: `homelab client add <hostname>` — appends a
|
||||
`hosts.<name>:` block to `inventory.yaml` (lifecycle `state: planned`
|
||||
→ `provisioning`, per [seeds/ontology.yaml](../../../seeds/ontology.yaml)),
|
||||
commits + pushes.
|
||||
2. Netbird join is **optional, not a required step** — only needed for
|
||||
hosts that must be reachable off-LAN (workstations that roam, e.g.
|
||||
`republic-laptop`, `mac-mini`). A node reachable on the household LAN
|
||||
(192.168.8.0/24 — most LXCs/VMs) doesn't need it: it's already
|
||||
reachable directly, and off-LAN clients reach it too via hubris's
|
||||
routed `192.168.8.0/24` Netbird network resource. Skip this step for
|
||||
LAN-only nodes; do it (out-of-band, console or setup key) only for
|
||||
hosts that need independent off-LAN reachability.
|
||||
3. On the new host: run `bootstrap.sh` (add `--with-nomos` to also
|
||||
enroll the Hermes agent). This provisions `/etc/age/key.txt`, the
|
||||
sync timer, and prints an age pubkey.
|
||||
4. Back on an enrolled client: `homelab client add <hostname>
|
||||
--finalize-pubkey <age1...>` — sets `age_pubkey`, grants shared
|
||||
secrets, re-keys SOPS, commits + pushes. This is the
|
||||
`provisioning → active` transition.
|
||||
5. Verify: `homelab doctor` on the new client should show all checks
|
||||
green (clone, sync timer, age key, CLI symlink, MCP reachable).
|
||||
|
||||
Docs-update checklist: if the new host is an LXC/VM, add its narrative
|
||||
page under `containers/` or `vms/` and set `doc_page` in its inventory
|
||||
entry (host-level cards don't have a `doc_page` field yet — services do;
|
||||
narrative pages are still found via the generated `see_also` in
|
||||
`inventory.yaml`).
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: config-change-deploy
|
||||
risk_class: config_mutation
|
||||
inputs: [service_name, change_description]
|
||||
verification: "curl -sf <service_url> (or homelab service <name> health)"
|
||||
docs_update_checklist: [doc_page, changelog]
|
||||
---
|
||||
|
||||
# Config change + deploy
|
||||
|
||||
Goal: change a tracked config repo (Caddy, Gitea customizations, an app's
|
||||
own repo) and get it live, safely.
|
||||
|
||||
1. `homelab change preflight <service>` — current health, the service's
|
||||
`config_repo`, its risk class, and the verification command to run
|
||||
after. If risk class requires approval (`config_mutation` or
|
||||
`destructive`), stop and get operator sign-off before editing — see
|
||||
`seeds/policy.yaml`.
|
||||
2. Clone/pull the `config_repo` (never edit the backend's working tree
|
||||
directly — tracked configs change by commit + push, per
|
||||
[OIKOS.md](../../OIKOS.md) conventions).
|
||||
3. Make the change, commit, push to `main`.
|
||||
4. The Gitea webhook fires the deploy pipeline for that repo (see
|
||||
[infrastructure/auto-deploy.md](../../../archive/knowledge/infrastructure/auto-deploy.md) for
|
||||
the exact receiver/reload for this service).
|
||||
5. Run the preflight's verification command. If it fails, check
|
||||
`homelab service <name> log` for the reload/restart error.
|
||||
6. Record the change: once `oikos/ledger.py` is wired into deploy tooling
|
||||
(Week 3), this is automatic; until then, note the change and outcome
|
||||
in the relevant investigation/plan doc.
|
||||
|
||||
Docs-update checklist: update the service's `doc_page` if the change
|
||||
alters its behavior, ingress route, or ownership; add a changelog entry
|
||||
if the page has one.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
name: docs-lint
|
||||
risk_class: read_only
|
||||
inputs: [paths]
|
||||
verification: "python3 .agents/skills/docs-lint/lint.py"
|
||||
docs_update_checklist: []
|
||||
---
|
||||
|
||||
# Docs lint
|
||||
|
||||
Check committed documentation against the mechanical rules in
|
||||
[writing-style.md](../../shared/writing-style.md): banned vocabulary and broken relative markdown
|
||||
links. Prose-voice rules are not machine-checkable — those stay a review responsibility.
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
python3 .agents/skills/docs-lint/lint.py # default: knowledge/ .agents/ operations/ investigations/ plans/
|
||||
python3 .agents/skills/docs-lint/lint.py archive/knowledge/containers/104-gitea.md
|
||||
|
||||
Exit code is non-zero when any violation is found, so it can gate a commit. The banned-vocabulary
|
||||
list mirrors `writing-style.md`; update both together if the standard changes.
|
||||
|
||||
> **Known baseline.** `archive/knowledge/archive/knowledge/containers/101-jellyfin.md` links into a sibling repo
|
||||
> (`devops/homelab-authentik-admin`) that this checkout does not contain — expected, not a bug.
|
||||
> Any other broken link is a real regression; investigate before dismissing it as baseline noise.
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint committed docs against .agents/shared/writing-style.md.
|
||||
|
||||
Checks two mechanical rules:
|
||||
1. Banned vocabulary (significance puffers, analytical verbs, poetic nouns,
|
||||
promotional adjectives, opening crutches).
|
||||
2. Broken relative markdown links.
|
||||
|
||||
Prose-voice rules are not machine-checkable; this covers the parts that are.
|
||||
Run from the repo root: python3 .agents/skills/docs-lint/lint.py [paths...]
|
||||
Exit 1 if any violation is found.
|
||||
"""
|
||||
import os, re, sys
|
||||
|
||||
BANNED = [
|
||||
"pivotal", "crucial", "vital", "groundbreaking", "transformative", "testament",
|
||||
"paramount", "invaluable", "delve", "leverage", "utilize", "facilitate", "foster",
|
||||
"showcase", "underscore", "streamline", "harness", "tapestry", "realm", "paradigm",
|
||||
"nexus", "cornerstone", "robust", "seamless", "innovative", "cutting-edge",
|
||||
"meticulous", "holistic", "comprehensive", "in today's world",
|
||||
"it's worth noting", "it is important to note",
|
||||
]
|
||||
BAN_RE = re.compile(r'(?<![\w-])(' + "|".join(re.escape(w) for w in BANNED) + r')(?![\w-])', re.I)
|
||||
LINK = re.compile(r'\]\(([^)]+)\)')
|
||||
|
||||
def iter_md(paths):
|
||||
for p in paths:
|
||||
if os.path.isfile(p) and p.endswith(".md"):
|
||||
yield p
|
||||
for root, dirs, files in os.walk(p):
|
||||
dirs[:] = [d for d in dirs if d not in (".git", "node_modules")]
|
||||
for f in files:
|
||||
if f.endswith(".md"):
|
||||
yield os.path.join(root, f)
|
||||
|
||||
def main(argv):
|
||||
paths = argv or ["knowledge", ".agents", "operations", "investigations", "plans"]
|
||||
violations = 0
|
||||
# The style guide and this skill enumerate the banned words by definition.
|
||||
ban_exempt = ("shared/writing-style.md", "skills/docs-lint/")
|
||||
for f in sorted(set(iter_md(paths))):
|
||||
check_banned = not any(x in f for x in ban_exempt)
|
||||
fence = False
|
||||
with open(f) as fh:
|
||||
for ln, line in enumerate(fh, 1):
|
||||
if line.lstrip().startswith("```"):
|
||||
fence = not fence; continue
|
||||
if fence:
|
||||
continue
|
||||
if check_banned:
|
||||
for m in BAN_RE.finditer(line):
|
||||
print(f"{f}:{ln}: banned word '{m.group(1)}'")
|
||||
violations += 1
|
||||
for m in LINK.finditer(line):
|
||||
link = m.group(1)
|
||||
if re.match(r'^(https?:|mailto:|#|/)', link):
|
||||
continue
|
||||
path = re.split(r'[#?]', link)[0]
|
||||
if not path:
|
||||
continue
|
||||
tgt = os.path.normpath(os.path.join(os.path.dirname(f), path))
|
||||
if not os.path.exists(tgt):
|
||||
print(f"{f}:{ln}: broken link -> {link}")
|
||||
violations += 1
|
||||
print(f"\n{violations} violation(s)")
|
||||
return 1 if violations else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: incident-investigation
|
||||
risk_class: read_only
|
||||
inputs: [symptom, affected_entity]
|
||||
verification: "n/a — investigation produces a written record, not a state change"
|
||||
docs_update_checklist: [investigations_entry]
|
||||
---
|
||||
|
||||
# Incident investigation
|
||||
|
||||
Goal: understand what broke and why, before touching anything.
|
||||
|
||||
1. `homelab service <name> explain` (or `homelab node <name> relations`
|
||||
if the affected entity is a host) — get the blast radius and doc
|
||||
pointer first. Don't start pulling logs blind.
|
||||
2. `homelab service <name> health` + `homelab service <name> log` (or
|
||||
MCP `get_service_status` / `tail_log`) for the affected service.
|
||||
3. Walk the blast radius: is a shared dependency down (`caddy`, `dns`,
|
||||
`authentik`, or the backend host itself)? `homelab node <name>
|
||||
relations` shows "affected by" — check those first.
|
||||
4. `homelab apt-audit` if the symptom looks like a dpkg/upgrade
|
||||
interaction.
|
||||
5. Check the change ledger for recent mutations to the affected entity
|
||||
or anything upstream of it: `homelab service <name> history` (once
|
||||
populated) or grep `ledger/*.jsonl`.
|
||||
6. Write findings to a new `knowledge/sources/investigations/<date>-<slug>.md` — symptom,
|
||||
timeline, root cause, fix applied, prevention. This is the durable
|
||||
record; don't rely on chat history.
|
||||
|
||||
Docs-update checklist: always create the investigation entry. If the
|
||||
root cause was stale/wrong inventory data (a `doc_page`, `config_repo`,
|
||||
or `backend` that didn't match reality — this happened during Week 1
|
||||
kernel work, see the `authentik` backend fix), correct `inventory.yaml`
|
||||
in the same session.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: lifecycle-activate-node
|
||||
risk_class: config_mutation
|
||||
inputs: [node_name]
|
||||
verification: "homelab service <name> health (if it hosts a service); homelab doctor (if it's a client)"
|
||||
docs_update_checklist: [doc_page_complete]
|
||||
transition: "provisioning -> active"
|
||||
---
|
||||
|
||||
# Lifecycle: activate a node
|
||||
|
||||
Per [seeds/ontology.yaml](../../../seeds/ontology.yaml). Requires: age key
|
||||
enrolled if it needs secrets, mesh joined if it needs off-LAN reach,
|
||||
ingress live if public, health check answering, doc page complete,
|
||||
ledger entry.
|
||||
|
||||
1. If the node is a `homelab` client: finish enrollment per
|
||||
[client-enrollment.md](../client-enrollment/SKILL.md) (`--finalize-pubkey`,
|
||||
mesh join, `homelab doctor` green).
|
||||
2. If it hosts a public service: add the `services:` entry in
|
||||
`inventory.yaml` (backend, url, doc_page, config_repo, risk_notes —
|
||||
see the Week-1 service contract fields) and wire the Caddy route in
|
||||
`dtoro/caddy-conf`.
|
||||
3. Confirm the health check answers: `homelab service <name> health` or
|
||||
a direct `curl`.
|
||||
4. Flip `state: provisioning` → `state: active` (or delete the `state:`
|
||||
field — `active` is the default) in `inventory.yaml`.
|
||||
5. Complete the doc page (stub → full narrative: role, specs, how it's
|
||||
configured, dependencies).
|
||||
6. Record the activation: `oikos/ledger.py append host:<name> activate
|
||||
config_mutation --result ok` (or let the CLI wrapper do this once
|
||||
Week 3's runbook automation lands).
|
||||
|
||||
Regenerate derived data: `python3 mcp/build_host_files.py && python3
|
||||
inventory.yaml` so `inventory.yaml`, the topology diagram, and
|
||||
the context card all reflect the new state.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: lifecycle-deprecate-node
|
||||
risk_class: config_mutation
|
||||
inputs: [node_name, replacement_node_or_reason]
|
||||
verification: "homelab node <name> relations — 'affected by' must be empty before completing"
|
||||
docs_update_checklist: [doc_page_deprecation_note]
|
||||
transition: "active -> deprecated"
|
||||
---
|
||||
|
||||
# Lifecycle: deprecate a node
|
||||
|
||||
Per [seeds/ontology.yaml](../../../seeds/ontology.yaml): a node keeps running
|
||||
but takes no new dependents. **Completion condition: zero remaining
|
||||
inbound `depends-on`/`routes-to` edges** — this is a hard gate, not a
|
||||
suggestion; `seeds/policy.yaml` `lifecycle_overrides.deprecated.refuse`
|
||||
lists `new-inbound-edges` as refused going forward.
|
||||
|
||||
1. Set `state: deprecated` on the node.
|
||||
2. `homelab node <name> relations` — read `affected_by`. Every entry
|
||||
there is something still relying on this node.
|
||||
3. Migrate or retire each dependent one at a time (point its `backend`/
|
||||
`config_repo`/ingress route elsewhere, or deprecate it too if it's
|
||||
being retired alongside).
|
||||
4. Re-run `homelab node <name> relations` after each dependent is moved.
|
||||
The transition to `destroyed` is only safe once `affected_by` is
|
||||
empty — check this every time, don't assume from memory.
|
||||
5. Note the deprecation on the doc page: reason, replacement (if any),
|
||||
date.
|
||||
|
||||
If step 2 shows dependents you didn't expect, stop and investigate
|
||||
before proceeding — that's exactly the kind of drift the Week-3 detector
|
||||
will catch automatically, but until then this manual check is the gate.
|
||||
|
||||
Next (once `affected_by` is empty):
|
||||
[lifecycle-destroy-node.md](../lifecycle-destroy-node/SKILL.md).
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: lifecycle-destroy-node
|
||||
risk_class: destructive
|
||||
inputs: [node_name]
|
||||
verification: "homelab node <name> relations returns unknown-entity; pct list on the backend no longer shows it"
|
||||
docs_update_checklist: [archaeology_entry, containers_index_update]
|
||||
transition: "deprecated -> destroyed"
|
||||
---
|
||||
|
||||
# Lifecycle: destroy a node
|
||||
|
||||
**Destructive.** Requires operator approval + typed confirmation phrase
|
||||
per `seeds/policy.yaml`. Requires (ontology): backups verified, secrets
|
||||
recipients removed + re-keyed, ingress/DNS removed, archaeology entry,
|
||||
ledger entry.
|
||||
|
||||
1. Confirm the node is `deprecated` with zero `affected_by` edges
|
||||
(`homelab node <name> relations`) — do not skip this even if the
|
||||
deprecation runbook was followed recently; state can drift.
|
||||
2. If it's an enrolled client: `homelab client remove <name>` — revokes
|
||||
the age key, re-keys SOPS, removes the inventory entry. This is
|
||||
already destructive-class and confirmed in the CLI.
|
||||
3. Remove any ingress route (Caddy config repo) and DNS record still
|
||||
pointing at it.
|
||||
4. Verify backups of anything on it are retained per policy before the
|
||||
disk goes away (see `backs-up-to`).
|
||||
5. Destroy the LXC/VM (`pct destroy` / `qm destroy`).
|
||||
6. Move the `hosts.<name>:` block (if any inventory remnant survives
|
||||
`client remove`, e.g. infra-only LXCs with no age key) into
|
||||
inventory.yaml's `archaeology:` section: `pve_id`, `destroyed` date,
|
||||
`reason`. Add a row to `containers/index.md` "Recently destroyed"
|
||||
table (kept for human-readable browsing alongside the structured
|
||||
data).
|
||||
7. `oikos/ledger.py append host:<name> destroy destructive --result ok`.
|
||||
8. Regenerate: `python3 mcp/build_host_files.py && python3
|
||||
inventory.yaml` — the node drops out of `inventory.yaml` and
|
||||
appears in the topology doc's archaeology table.
|
||||
|
||||
If the destroy fails partway (e.g. secrets revoked but pct destroy
|
||||
errors), do not re-run step 2 — `client remove` is not idempotent
|
||||
against a second revocation attempt on the issuance server. Finish the
|
||||
remaining steps manually and note the partial state in an investigation.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: lifecycle-migrate-node
|
||||
risk_class: config_mutation
|
||||
inputs: [node_name, source_host, target_host]
|
||||
verification: "homelab node <name> relations (re-check blast radius); homelab service <svc> health for every hosted service"
|
||||
docs_update_checklist: [doc_page_migration_note, inventory_host_and_lan_ip]
|
||||
transition: "active -> migrating -> active"
|
||||
---
|
||||
|
||||
# Lifecycle: migrate a node
|
||||
|
||||
Modeled on the strong Phase 1+2 migration
|
||||
([plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
|
||||
Requires (ontology): preflight + backup-verified before migrating;
|
||||
post-verify + Caddy backends checked + mounts checked + docs updated
|
||||
before returning to `active`.
|
||||
|
||||
1. `homelab change preflight <every service the node hosts>` — capture
|
||||
current health as a baseline.
|
||||
2. Verify backups are current for anything with data at rest on the
|
||||
node (see `backs-up-to` edges once populated).
|
||||
3. Set `state: migrating` in `inventory.yaml`.
|
||||
4. Perform the migration (pct/qm move, or create-on-target +
|
||||
data-copy + destroy-source, per the specific case).
|
||||
5. Update `inventory.yaml`: new `host:`, `lan_ip`, `mesh` addresses for
|
||||
the node; update every `services:` entry whose `backend` pointed at
|
||||
it if the backend name itself changes (usually it doesn't — only the
|
||||
`host:`/`lan_ip` on the guest entry moves).
|
||||
6. Post-verify: re-run the Week-1 drift check by hand — confirm Caddy's
|
||||
backend IP for each affected service matches the new `lan_ip`
|
||||
(automatic in Week 3's drift detector), confirm mounts still resolve.
|
||||
7. `homelab service <name> health` for every service the node hosts.
|
||||
8. Set `state: active`. Add a migration note to the node's doc page
|
||||
(old host/IP → new, date, phase reference) — this repo's convention
|
||||
for every past migration (see `archive/knowledge/containers/101-jellyfin.md`,
|
||||
`containers/129-house.md`).
|
||||
|
||||
Regenerate: `python3 mcp/build_host_files.py && python3
|
||||
inventory.yaml`.
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: lifecycle-provision-node
|
||||
risk_class: config_mutation
|
||||
inputs: [node_name, kind, storage_pool]
|
||||
verification: "grep 'state: provisioning' inventory.yaml"
|
||||
docs_update_checklist: [doc_page_stub]
|
||||
transition: "planned -> provisioning"
|
||||
---
|
||||
|
||||
# Lifecycle: provision a node
|
||||
|
||||
Per [seeds/ontology.yaml](../../../seeds/ontology.yaml) `lifecycle.transitions`.
|
||||
Policy note: `provisioning` nodes get a lifecycle override —
|
||||
`config_mutation` actions downgrade to `reversible_low` because nothing
|
||||
depends on the node yet (see `seeds/policy.yaml` `lifecycle_overrides`).
|
||||
|
||||
Requires (from ontology): inventory entry, IP reserved, storage pool
|
||||
chosen, doc page stub.
|
||||
|
||||
1. Create the LXC/VM on its target Proxmox host (`pct create` /
|
||||
`qm create`), choosing the storage pool deliberately — record it as
|
||||
the `storage:` field once populated (Week 1 schema; not yet backfilled
|
||||
for existing nodes).
|
||||
2. Add the inventory entry: `homelab client add <name>` for anything that
|
||||
will run the `homelab` CLI, or a direct `hosts.<name>:` block with
|
||||
`state: provisioning`, `kind`, `host`, `pve_id`, `lan_ip` for
|
||||
infra-only LXCs that won't self-enroll.
|
||||
3. Stub the doc page (`containers/<pve_id>-<name>.md` or
|
||||
`vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X"
|
||||
is enough to satisfy the transition requirement.
|
||||
4. Reserve the IP in DNS/DHCP notes if it's a fixed LAN address.
|
||||
|
||||
Next: [lifecycle-activate-node.md](../lifecycle-activate-node/SKILL.md).
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
name: budget-import-from-csv
|
||||
risk_class: config_mutation
|
||||
inputs: [csv_file]
|
||||
references: [containers/129-house.md]
|
||||
---
|
||||
|
||||
# Runbook: Budget import from N26 CSV → Yuvomi
|
||||
|
||||
Distil a bank-export CSV into Yuvomi's Budget and Subscriptions modules using
|
||||
the `yuvomi-mcp` tools. Run this whenever a new CSV period needs to be
|
||||
summarised into targets and fixed costs.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `yuvomi-mcp` is running on LXC 129 and connected as an MCP server in Claude.
|
||||
- The CSV is an N26 export (columns: Booking Date, Value Date, Partner Name,
|
||||
Partner Iban, Type, Payment Reference, Account Name, Amount (EUR), …).
|
||||
- API token: `homelab secret yuvomi-api-token` (decrypts on any enrolled client).
|
||||
- Direct API base: `https://house.hubris.network/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## API quirks (Yuvomi ≤ 0.77.x)
|
||||
|
||||
- **Subscriptions live under `/budget/subscriptions`**, NOT `/subscriptions/`.
|
||||
A top-level `/subscriptions` route returns 404.
|
||||
- `GET /budget/subscriptions` → `{ data: { subscriptions: [...], summary: {...} } }`
|
||||
- `GET /budget/subscriptions/meta` → `{ data: { categories: [...], payment_methods: [...] } }`
|
||||
- `POST /budget/subscriptions` → create a subscription (name, amount, billing_cycle,
|
||||
cycle_interval, next_payment_date, currency, category_id, payment_method_id required)
|
||||
- `GET /budget/` (no month) → returns only **non-recurring** base entries.
|
||||
Use `GET /budget/?month=YYYY-MM` to get all entries (recurring + one-time) for a month.
|
||||
- `GET /budget/categories` → expense category keys + income category names (German keys
|
||||
like `"Erwerbseinkommen"`, `"Sozialleistungen"`, `"Geschenke & Transfers"`).
|
||||
- Budget entries: `amount` positive = income, negative = expense.
|
||||
- Recurring entries: set `is_recurring: 1` + `recurrence_interval: "monthly"`.
|
||||
The `date` field sets the start month.
|
||||
- `recurrence_virtual: 1` smooths non-monthly amounts across all months in the summary
|
||||
(e.g. 55.08 € quarterly → shows as ~18.36 €/month).
|
||||
- Custom RRULE strings (`recurrence_rule`) are **not accepted** by the API — use
|
||||
`cycle_interval` on the subscription instead, or `recurrence_interval` on budget entries.
|
||||
|
||||
---
|
||||
|
||||
## Subscription category IDs (as of 2026-06-26)
|
||||
|
||||
| id | name | budget_subcategory_key |
|
||||
|---|---|---|
|
||||
| 1 | Entertainment | subscription_entertainment |
|
||||
| 2 | Productivity | subscription_productivity |
|
||||
| 3 | Utilities | subscription_utilities |
|
||||
| 4 | Health | subscription_health |
|
||||
| 5 | Education | subscription_education |
|
||||
| 6 | Other | subscription_other |
|
||||
|
||||
## Payment method IDs
|
||||
|
||||
| id | name |
|
||||
|---|---|
|
||||
| 1 | Credit Card |
|
||||
| 2 | Debit Card |
|
||||
| 3 | PayPal |
|
||||
| 6 | Bank Transfer / SEPA |
|
||||
| 7 | Other |
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Categorise the transactions
|
||||
|
||||
Skip these as internal/already-covered:
|
||||
- Fixed costs you'll enter as **subscriptions** (Miete, SWM, SYNVIA, Hundefutter,
|
||||
Netflix, Grover, Rundfunk ARD, KuKita, Lillydoo)
|
||||
- Internal transfers (The Joy Pot ↔ Cookie, Hauptkonto, Tagesgeldkonto splits)
|
||||
- Identified income (Cookie Share, Kindergeld, Pocket Money credits, Distributor)
|
||||
- Fun Money pass-throughs (in and out same month → net zero)
|
||||
|
||||
**Variable expense taxonomy:**
|
||||
|
||||
| Category key | Subcategory key | Examples |
|
||||
|---|---|---|
|
||||
| `food` | `groceries` | E-Center, Knuspr, EDEKA, Tegut, VollCorner, Lidl, Netto, REWE, KoRo, Roast Market |
|
||||
| `food` | `restaurants_bars` | Restaurants, Lieferando, Cafes, Zeit für Brot, Baobab, Höflinger |
|
||||
| `personal_health` | `beauty_cosmetics` | DM, Rossmann |
|
||||
| `personal_health` | `pharmacy` | Apotheke, MVZ Dermatologie |
|
||||
| `transport` | `apps_taxi` | Uber, RYD GMBH, MVG, Handyparken |
|
||||
| `shopping_clothing` | `gifts` | Children products: Schlummersack, Catchy Kids, SP EVERY., Dukal, Berger-Lernwelt |
|
||||
| `shopping_clothing` | `clothes_shoes` | Zalando, Ernsting's, Schuhmair, Thalia, Vinted, Airbnb, Hotel at Booking.com |
|
||||
| `shopping_clothing` | `electronics` | Amazon, AMZN Mktp DE |
|
||||
| `housing` | `renovation_maintenance` | IKEA, Markus Festl, Granit, Sostrene Grene, Mol* tischdecken, Gaertnerei, Dehner |
|
||||
| `education` | `courses_college` | Kathrin Orlob (PEKiP), Nerina Aupperle |
|
||||
| `leisure` | `streaming` | WOW wowtv.de |
|
||||
| `financial_other` | `bank_fees` | Unidentified PayPal, Ratepay, N26 fees |
|
||||
| `Geschenke & Transfers` | *(income)* | One-off incoming transfers |
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Create subscriptions
|
||||
|
||||
```
|
||||
get_subscriptions_meta() ← get category_id and payment_method_id
|
||||
```
|
||||
|
||||
**Standard Cookie household subscriptions (as of 2026-07):**
|
||||
|
||||
| Name | Amount | billing_cycle | cycle_interval | category_id | payment_method_id |
|
||||
|---|---|---|---|---|---|
|
||||
| Miete | 1080.00 | monthly | 1 | 6 (Other) | 6 (Bank Transfer) |
|
||||
| Strom (SWM) | 79.00 | monthly | 1 | 3 (Utilities) | 6 |
|
||||
| Internet / TV / Telefon | 29.99 | monthly | 1 | 3 (Utilities) | 6 |
|
||||
| Hundefutter | 75.00 | monthly | 1 | 6 (Other) | 6 |
|
||||
| Netflix | 8.00 | monthly | 1 | 1 (Entertainment) | 6 |
|
||||
| Grover | 16.90 | monthly | 1 | 6 (Other) | 2 (Debit Card) |
|
||||
| Rundfunk ARD / ZDF | 55.08 | monthly | 3 | 1 (Entertainment) | 6 |
|
||||
| KuKita Daycare (Leon) | 503.00 | monthly | 1 | 5 (Education) | 6 |
|
||||
| Lillydoo diapers | 56.70 | monthly | 2 | 4 (Health) | 3 (PayPal) |
|
||||
|
||||
Monthly equivalent total: **1,838.60 €** (Yuvomi applies cycle_interval to prorate).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Add recurring income entries
|
||||
|
||||
```
|
||||
stage_add_budget_entry(
|
||||
title="Kindergeld",
|
||||
amount=55.00,
|
||||
category="Sozialleistungen",
|
||||
date="YYYY-MM-01",
|
||||
is_recurring=True,
|
||||
recurrence_interval="monthly",
|
||||
)
|
||||
commit_pending(pending_id)
|
||||
```
|
||||
|
||||
**Standard recurring income:**
|
||||
|
||||
| Title | Amount | category |
|
||||
|---|---|---|
|
||||
| Kindergeld | +55.00 | Sozialleistungen |
|
||||
| Cookie Share | +2650.00 | Erwerbseinkommen *(see recommended amount below)* |
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Post variable transactions
|
||||
|
||||
For each non-skipped CSV row, call `stage_add_budget_entry` with the mapped
|
||||
category/subcategory and the actual transaction amount and date. Use the Partner
|
||||
Name + Payment Reference as the title (truncate to 100 chars).
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Verify
|
||||
|
||||
```
|
||||
get_budget_summary("YYYY-MM")
|
||||
list_subscriptions()
|
||||
```
|
||||
|
||||
Expected for a full month with KuKita:
|
||||
- Fixed expenses ≥ 1,838 € (subscriptions)
|
||||
- Variable expenses ≥ 500 € (groceries alone)
|
||||
|
||||
---
|
||||
|
||||
## Cookie Share: how much to transfer monthly
|
||||
|
||||
Calculated from Jan–Jun 2026 data (Cookie account, one-offs stripped):
|
||||
|
||||
| | €/month |
|
||||
|---|---|
|
||||
| **Fixed costs (subscriptions)** | **1,839** |
|
||||
| Miete | 1,080 |
|
||||
| KuKita *(permanent from Jul 2026)* | 503 |
|
||||
| Strom + SYNVIA + Rundfunk + Netflix + Grover + Hundefutter + Lillydoo | 256 |
|
||||
| **Variable (6-month averages)** | **1,032** |
|
||||
| Groceries | 595 |
|
||||
| Children products | 142 |
|
||||
| Dining & cafes | 100 |
|
||||
| Transport | 66 |
|
||||
| Drugstore | 52 |
|
||||
| Clothing, Amazon, Pharmacy | 77 |
|
||||
| **Total monthly spend** | **≈ 2,871** |
|
||||
| Minus Kindergeld (fixed income) | −55 |
|
||||
| Minus Pocket Money (conservative ~600 €) | −600 |
|
||||
| **→ Recommended Cookie Share** | **≈ 2,650 €** |
|
||||
| With 200 € buffer | **≈ 2,850 €** |
|
||||
|
||||
**Current Cookie Share (Jun 2026): 1,995 € — shortfall ~655 €.**
|
||||
|
||||
The gap was covered by irregular Pocket Money top-ups (avg 962 €/mo over 6 months, but
|
||||
highly variable: 121 €–3,000 €). KuKita starting in June is the biggest step-up; raising
|
||||
Cookie Share to **2,650 €** makes the budget self-sufficient without relying on top-ups.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-29 — Corrections from first real import
|
||||
- Subscriptions endpoint is `/budget/subscriptions`, NOT `/subscriptions/` (404).
|
||||
- `recurrence_rule` RRULE strings are rejected by the API; use `cycle_interval` instead.
|
||||
- `GET /budget/` (no filter) returns only non-recurring entries; use `?month=` for full view.
|
||||
- Added Cookie Share recommendation (2,650 €/month) based on 6-month expense analysis.
|
||||
- Added full category taxonomy table.
|
||||
|
||||
### 2026-06-29 — Initial runbook
|
||||
Created from Jan–Jun 2026 N26 Cookie account analysis.
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: service-health-check
|
||||
risk_class: read_only
|
||||
inputs: [service_name]
|
||||
verification: "homelab service <name> health"
|
||||
docs_update_checklist: []
|
||||
---
|
||||
|
||||
# Service health check
|
||||
|
||||
Goal: determine whether a service is actually healthy, without ad-hoc SSH.
|
||||
|
||||
1. `homelab service <name> explain` — read the context card: backend,
|
||||
blast radius, doc pointer, risk notes.
|
||||
2. `homelab service <name> health` — live health probe (HTTP code against
|
||||
the service's `url`/`endpoint`). Once the Week-3 scheduler ships, this
|
||||
reads a cached snapshot by default; pass `--live` to force a fresh probe.
|
||||
3. If unhealthy, `homelab service <name> log` (or MCP `tail_log`) for the
|
||||
last 200 lines.
|
||||
4. Cross-check blast radius: `homelab node <name> relations` — is this
|
||||
entity's own backend host healthy? A downstream failure (e.g. `strong`
|
||||
down) will show up here before the service's own logs explain anything.
|
||||
5. If the fix is a restart: classify first (`seeds/policy.yaml` —
|
||||
`service-restart` is `reversible_low` unless the service has a
|
||||
`service_overrides` entry, e.g. `caddy`/`dns` are `config_mutation`).
|
||||
Unattended agents may act on `reversible_low` without approval.
|
||||
|
||||
Docs-update checklist: none for a pure health check. If the investigation
|
||||
reveals stale `risk_notes` or a wrong `doc_page`, fix `inventory.yaml` in
|
||||
the same session.
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "web", "run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
# Oikos CI (Gitea Actions). Gates the deploy webhook on a green run (plan M1).
|
||||
# Mirrors `make lint`, `make test`, and the generated-code drift guard.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: timescale/timescaledb:2.17.2-pg16
|
||||
env:
|
||||
POSTGRES_DB: oikos
|
||||
POSTGRES_USER: oikos
|
||||
POSTGRES_PASSWORD: oikos_dev
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U oikos"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
OIKOS_TEST_DATABASE_URL: postgres://oikos:oikos_dev@postgres:5432/oikos?sslmode=disable
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout 5m
|
||||
continue-on-error: true # advisory until the lint baseline is clean
|
||||
|
||||
- name: govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./... || true # advisory
|
||||
|
||||
- name: generated code is up to date
|
||||
run: make generate-check
|
||||
|
||||
- name: build
|
||||
run: go build ./...
|
||||
|
||||
- name: test (race + coverage)
|
||||
run: go test -race -covermode=atomic -coverprofile=coverage.out -timeout 300s ./...
|
||||
|
||||
- name: coverage gates (policy + learning ≥ 80%, others ≥ 60%)
|
||||
run: |
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
# Note: policy/ and learning/ packages land in Phase 3; enforce
|
||||
# their 80% gate then. For now, report total coverage.
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: docker build (verify image builds; no push)
|
||||
run: docker build -f compose/oikos/Dockerfile -t oikos:ci .
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,26 +0,0 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Regenerated every scheduler run; ephemeral health-probe cache.
|
||||
oikos/state.json
|
||||
|
||||
# Compiled binaries (Go rewrite — bin/oikos, bin/nomos)
|
||||
bin/oikos
|
||||
bin/nomos
|
||||
oikos/oikos
|
||||
|
||||
# Legacy Python oikos (superseded by cmd/oikos Go binary — Phase 1-6 rewrite).
|
||||
# oikos/ kernel files are still imported by bin/homelab for operational CLI
|
||||
# commands (ssh, pct, logs, restart, status, open, secret, client, sync, mcp).
|
||||
# Remove oikos/* when bin/homelab is ported to Go.
|
||||
backups/
|
||||
.env
|
||||
.infisical-credentials
|
||||
|
||||
# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the
|
||||
# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a
|
||||
# fresh checkout before the UI is built.
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
web/node_modules/
|
||||
107
.sops.yaml
107
.sops.yaml
@@ -1,12 +1,14 @@
|
||||
# SOPS recipient rules.
|
||||
#
|
||||
# SOPS is DR-fallback only — Infisical is the active secrets backend.
|
||||
# Files live in archive/secrets-sops-backup/ for cold recovery.
|
||||
# To encrypt a new DR secret: sops -e archive/secrets-sops-backup/<name>.yaml
|
||||
# Each rule pins one or more age public keys as recipients for files matching
|
||||
# the path_regex. The build_host_files.py generator doesn't read this file;
|
||||
# `sops` does — to encrypt a new secret, run `sops -e secrets/<name>.yaml`
|
||||
# from the repo root and SOPS will pick the matching rule below.
|
||||
#
|
||||
# To grant a DR secret to a new client: add their age public key (from
|
||||
# To grant a secret to a new client: add their age public key (from
|
||||
# inventory.yaml `hosts.<name>.age_pubkey`) to the relevant rule below, then
|
||||
# run `sops updatekeys archive/secrets-sops-backup/<name>.yaml` to re-encrypt.
|
||||
# run `sops updatekeys secrets/<name>.yaml` to re-encrypt without rotating
|
||||
# the ciphertext payload.
|
||||
#
|
||||
# To revoke: remove the recipient from the relevant rule and run
|
||||
# `sops updatekeys` (this is what `homelab client remove` calls). Past
|
||||
@@ -14,43 +16,35 @@
|
||||
# underlying credential if compromise is suspected.
|
||||
|
||||
creation_rules:
|
||||
- path_regex: ^archive/secrets-sops-backup/hello\.yaml$
|
||||
- path_regex: ^secrets/hello\.yaml$
|
||||
# The "hello" secret is encrypted to every enrolled client so the bootstrap
|
||||
# decrypt test works for everyone. Add each new client's age_pubkey when
|
||||
# they enrol; re-key with `sops updatekeys -y secrets/hello.yaml`.
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
|
||||
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
|
||||
age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4,
|
||||
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: archive/secrets-sops-backupgitea-pat\.yaml$
|
||||
- path_regex: ^secrets/gitea-pat\.yaml$
|
||||
# Write-scoped Gitea PAT (dtoro user). Same recipient list as hello.yaml
|
||||
# since every enrolled client should be able to push (homelab client
|
||||
# add/remove, wiki edits, etc.).
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
|
||||
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
|
||||
age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4,
|
||||
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: archive/secrets-sops-backupgitea-tokens\.yaml$
|
||||
- path_regex: ^secrets/gitea-tokens\.yaml$
|
||||
# Workstations only.
|
||||
age: >-
|
||||
# placeholder — fill with age_pubkey of: republic-laptop, mac-mini, strong, hubris
|
||||
# placeholder — fill with age_pubkey of: republic-laptop, mac-mini, ludo-mini, hubris
|
||||
|
||||
- path_regex: archive/secrets-sops-backupwebhook-hmacs\.yaml$
|
||||
- path_regex: ^secrets/webhook-hmacs\.yaml$
|
||||
# LXCs that run a webhook receiver.
|
||||
age: >-
|
||||
# placeholder — fill with age_pubkey of: apps, caddy
|
||||
# placeholder — fill with age_pubkey of: apps, caddy, claudio-bot, claudio-monitor host
|
||||
|
||||
- path_regex: archive/secrets-sops-backupturn-shared-secret\.yaml$
|
||||
- path_regex: ^secrets/turn-shared-secret\.yaml$
|
||||
# coturn TURN long-term-credential password. Consumed by hubris (which
|
||||
# renders /etc/turnserver.conf + /opt/management.json on the VPS via
|
||||
# `homelab render-vps-configs`). Other recipients are convenience for
|
||||
@@ -58,77 +52,24 @@ creation_rules:
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
|
||||
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
|
||||
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: archive/secrets-sops-backupnetbird-authentik-oidc\.yaml$
|
||||
- path_regex: ^secrets/netbird-authentik-oidc\.yaml$
|
||||
# Authentik OIDC client secret for the netbird-dashboard provider.
|
||||
# Consumed by hubris to render /opt/management.json on the VPS
|
||||
# (PKCEAuthorizationFlow.ProviderConfig.ClientSecret).
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
|
||||
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
|
||||
age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
|
||||
- path_regex: archive/secrets-sops-backupnetbird-pat\.yaml$
|
||||
# NetBird API Personal Access Token. Consumed by the dns-sync job on the
|
||||
# `dns` LXC (107) to reconcile Technitium -> NetBird managed DNS zone.
|
||||
# (When 107 is enrolled, add its age_pubkey here and updatekeys.)
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
|
||||
|
||||
- path_regex: archive/secrets-sops-backupopenrouter-api-key\.yaml$
|
||||
# OpenRouter API key consumed by the `nomos` wrapper (bin/nomos) when
|
||||
- path_regex: ^secrets/openrouter-api-key\.yaml$
|
||||
# OpenRouter API key consumed by the `hermes` wrapper (bin/hermes) when
|
||||
# spawning a Goose session. Recipients are any host that should run a
|
||||
# Nomos agent. Add a host's age_pubkey here, then
|
||||
# Nous-Hermes agent. Add a host's age_pubkey here, then
|
||||
# `sops updatekeys -y secrets/openrouter-api-key.yaml`.
|
||||
# See operations/nomos-agent.md.
|
||||
# See operations/hermes-agent.md.
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
|
||||
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
|
||||
|
||||
- path_regex: archive/secrets-sops-backupyuvomi-api-token\.yaml$
|
||||
# Named Bearer token for the Yuvomi REST API, consumed by yuvomi-mcp on
|
||||
# LXC 129 (house).
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
|
||||
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
|
||||
|
||||
- path_regex: archive/secrets-sops-backuphermes-house-users\.yaml$
|
||||
# Signal number → Yuvomi user_id mapping (PII). Consumed by hermesd on LXC 129.
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6,
|
||||
age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs,
|
||||
age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
|
||||
|
||||
- path_regex: archive/secrets-sops-backupoikos-approval-hmac\.yaml$
|
||||
# HMAC signing key for Oikos approval-grant tokens (oikos/approve.py).
|
||||
# Recipients: apps (105, runs the approval engine alongside homelab-mcp)
|
||||
# and hubris (admin/debug decrypt). See OIKOS.md "Approval engine".
|
||||
age: >-
|
||||
age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6,
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
|
||||
- path_regex: archive/secrets-sops-backupoikos-console-deploy-secret\.yaml$
|
||||
# Shared HMAC secret for the Gitea deploy webhook (id 14) ->
|
||||
# oikos-console-deploy.service on apps (105). Generated + registered
|
||||
# with Gitea before the apps-side install ran (see
|
||||
# oikos/console/deploy/README.md "Status") — write this exact value
|
||||
# into /etc/oikos-console-deploy/secret rather than letting
|
||||
# webhook/install.sh generate a fresh one.
|
||||
age: >-
|
||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
||||
# webhook noop 2026-05-20T18:16:57+02:00
|
||||
|
||||
186
AGENTS.md
186
AGENTS.md
@@ -4,33 +4,11 @@ You are running on a machine that is part of the **hubris** homelab. The full
|
||||
context is in this checkout at `/opt/homelab-context/`. This file is the entry
|
||||
point. Read it once at start, then keep working.
|
||||
|
||||
- **New client?** Read [CLIENTS.md](CLIENTS.md) first.
|
||||
- **Developing on this repo?** Also read [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
|
||||
|
||||
The operating model — OODA loop, risk classes, approval rules, the ontology,
|
||||
and node lifecycle — is defined in [OIKOS.md](.agents/OIKOS.md). Before any mutation,
|
||||
classify the action against `seeds/policy.yaml`; when the class requires
|
||||
approval, stop and ask the operator.
|
||||
|
||||
Agent-facing instruction lives under `.agents/`:
|
||||
`.agents/shared/` holds the conventions every agent applies
|
||||
([writing-style](.agents/shared/writing-style.md), [caveman](.agents/shared/caveman.md),
|
||||
[page-templates](.agents/shared/page-templates.md), [llm-wiki](.agents/shared/llm-wiki.md)), and
|
||||
`.agents/domains/` holds the per-domain schemas
|
||||
([knowledge](.agents/domains/knowledge/schema.md), [operations](.agents/domains/operations/schema.md)).
|
||||
|
||||
**Source of truth:** The Postgres database is the single source of truth for all
|
||||
structured data and knowledge. It is bootstrapped from `seeds/` at deploy time:
|
||||
`seeds/ontology.yaml` (entity types, relationships, lifecycles), `seeds/inventory.yaml`
|
||||
(hosts, services, entities), `seeds/policy.yaml` (risk classes, approval rules), and
|
||||
`seeds/knowledge.yaml` (documents, investigations, runbooks). The old narrative wiki
|
||||
is archived at `archive/knowledge/` for historical reference.
|
||||
|
||||
## 1. Who you are
|
||||
|
||||
Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
|
||||
|
||||
/opt/homelab-context/inventory.yaml
|
||||
/opt/homelab-context/hosts/<your-hostname>.yaml
|
||||
|
||||
That file tells you your role, your peers, what's mounted, and what services
|
||||
you host. If it does not exist, this client was not enrolled — stop and tell
|
||||
@@ -39,131 +17,73 @@ the operator to run `homelab client add <hostname>` from an existing client.
|
||||
## 2. The topology
|
||||
|
||||
- `/opt/homelab-context/inventory.yaml` — every host, LXC, VM, and workstation
|
||||
with their mesh addresses, roles, and service mappings. This is the seed file;
|
||||
at runtime the DB is authoritative (query via MCP `get_entity` or the REST API).
|
||||
- `/opt/homelab-context/seeds/knowledge.yaml` — full narrative knowledge: 36
|
||||
documents, 6 investigations, 12 runbooks. Ingested into the DB on deploy.
|
||||
- `/opt/homelab-context/.agents/operations/commands.md` — the operator's cheatsheet
|
||||
for pct, caddy, DNS, and the Oikos command surface.
|
||||
with their mesh addresses, roles, and service mappings. Treat this file as
|
||||
authoritative; anything you read in narrative pages should agree with it.
|
||||
- `/opt/homelab-context/infrastructure/mesh.md` — Tailscale → Netbird state.
|
||||
Both meshes are accepted today; Netbird is preferred for new traffic.
|
||||
- `/opt/homelab-context/infrastructure/dns.md` — split-horizon DNS via
|
||||
dnsmasq on LXC 124. `*.hubris.network` resolves to 192.168.x.x on the LAN
|
||||
and to mesh addresses off-LAN.
|
||||
- `/opt/homelab-context/operations/commands.md` — the operator's cheatsheet
|
||||
for pct, caddy, dnsmasq. Use these verbs when you take actions.
|
||||
|
||||
## 3. The MCP server
|
||||
|
||||
The homelab exposes a Model Context Protocol server with structured tools.
|
||||
Endpoint: `https://mcp.hubris.network/mcp`.
|
||||
Endpoint is in `inventory.yaml` under `services.homelab_mcp.endpoint`.
|
||||
|
||||
Available tools (21 total):
|
||||
Available tools:
|
||||
|
||||
Context — observe + orient:
|
||||
get_entity(slug), list_entities(type, limit, cursor),
|
||||
get_relations(entity), get_blast_radius(entity),
|
||||
search_knowledge(query) — ILIKE search over documents, investigations,
|
||||
runbooks in the knowledge_entities table
|
||||
get_patterns(status, entity_type, action) — learned action patterns
|
||||
get_skills(status) — available automation skills
|
||||
Context (pure read):
|
||||
get_host(name), list_services(), find_service(name_or_role),
|
||||
get_topology(), search_docs(query), get_page(path),
|
||||
get_changelog(page, since?), whoami(hostname),
|
||||
list_my_secrets(caller_pubkey?)
|
||||
|
||||
Management — live state:
|
||||
get_service_status(service_slug) — systemctl is-active on target host
|
||||
tail_log(service_slug, lines=200) — journalctl
|
||||
list_lxcs() — all LXC containers with ID, host, IP, health
|
||||
get_lxc_state(lxc_slug) — pct status from Proxmox host
|
||||
ping_service(service_slug) — HTTP reachability from entity_status
|
||||
Management (read-only):
|
||||
get_service_status(service), tail_log(service, lines=200),
|
||||
list_lxcs(), get_lxc_state(lxc), ping_service(service)
|
||||
|
||||
Oikos — decisions:
|
||||
explain(service_slug) — compact context card (type, state, health, relations)
|
||||
preflight(service_slug, action) — risk class + approval requirement
|
||||
whoami(hostname) — entity record, peers, health for a client
|
||||
get_change_history(entity_slug, limit=20) — last audit-log entries per entity
|
||||
get_state_snapshot() — fleet health, disk, drift count
|
||||
Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with
|
||||
operator confirmation.
|
||||
|
||||
Operations — observe + act:
|
||||
get_health_summary() — fleet health counts (healthy/degraded/down/unknown)
|
||||
get_signal_history(entity_slug, state, limit) — open + recent signals
|
||||
get_audit_trail(entity_id) — audit log filter + browse
|
||||
get_agent_activity(limit) — agent self-inspection
|
||||
query_metrics(hours=24) — time-series metric bucketed averages
|
||||
get_trend(entity_id, days=7) — metric slope over time
|
||||
get_event_timeline(severity, entity_slug, limit) — recent events
|
||||
**When to prefer MCP over grepping the clone:** any time you need to resolve a
|
||||
name to an address, look up service status, or search the wiki by content.
|
||||
Grep is fine for browsing or when MCP is unreachable.
|
||||
|
||||
Execution — the single mutation path:
|
||||
request_execution(target, action, params) — policy-gated.
|
||||
reversible_low (restart, reload, pct_exec, apt audit) runs immediately;
|
||||
config_mutation (systemctl enable/disable, apt upgrade) queues for operator
|
||||
approval via Matrix, then executes on ✅.
|
||||
get_execution_status(execution_id) — poll progress
|
||||
## 4. Wiki conventions
|
||||
|
||||
**When to prefer MCP over grepping the clone:** always for knowledge queries.
|
||||
`search_knowledge("jellyfin hardware acceleration")` returns ranked results from
|
||||
the DB with entity links. `get_entity_knowledge("lxc:jellyfin")` returns documents,
|
||||
runbooks, and investigations in one call. Grep the clone only when MCP is
|
||||
unreachable.
|
||||
- Pages live under `containers/`, `hosts/`, `vms/`, `infrastructure/`,
|
||||
`investigations/`, `operations/`. Cross-link liberally; orphans are bugs.
|
||||
- Every page ends with a `## Changelog` section, entries in reverse-chrono
|
||||
order:
|
||||
|
||||
## 4. Knowledge conventions
|
||||
### YYYY-MM-DD — short title
|
||||
one or two lines describing what changed and why.
|
||||
|
||||
All narrative knowledge (documents, investigations, runbooks) lives in the DB
|
||||
(`knowledge_entities` table) and is seeded from `seeds/knowledge.yaml`. Agents
|
||||
can register new knowledge via the API:
|
||||
|
||||
```
|
||||
POST /api/v1/knowledge/{entity_slug}
|
||||
{"title": "...", "content": "...", "tags": ["..."]}
|
||||
```
|
||||
|
||||
The DB is the truth. The old wiki files are in `knowledge/wiki/` pending archive
|
||||
per the DB-as-source-of-truth plan.
|
||||
|
||||
- **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md
|
||||
files under `.agents/skills/<name>/`. They carry `risk_class`, `procedure`
|
||||
(JSON-schema-validated), and are linked to entity types via `applies_to_type`.
|
||||
- **Investigations** are `investigation` entities linked to affected entities
|
||||
via `about` edges.
|
||||
- **Documents** are `document` entities linked to entities via `documents` edges.
|
||||
They carry `at_glance` (structured attributes) and `changelog` (parsed entries).
|
||||
- **Live state precedence.** If you observe a discrepancy between the docs and
|
||||
running state, update the DB *in the same session* via the API. The `oikos export`
|
||||
command regenerates `seeds/knowledge.yaml` for version control.
|
||||
- Investigation files are dated and slugged: `YYYY-MM-DD-slug.md`.
|
||||
- Live state takes precedence over docs. If you observe a discrepancy, update
|
||||
the docs *in the same session* (per the same-session update rule).
|
||||
|
||||
## 5. Acting on the homelab
|
||||
|
||||
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary
|
||||
operator interface — it has 21 MCP tools for observe/orient/decide/act.
|
||||
- **Actions** (restart, logs, apt, pct exec): Nomos calls `request_execution`
|
||||
via MCP. `reversible_low` actions execute immediately; `config_mutation`
|
||||
and `destructive` actions are queued for operator approval via Matrix.
|
||||
- **Secrets**: managed by Infisical (`oikos secret` subcommand for migration).
|
||||
Never hardcode secrets — use env vars from `.env`.
|
||||
- **Mutations** (restart, edit configs, etc.): classified against
|
||||
`seeds/policy.yaml`. `reversible_low` actions auto-execute;
|
||||
`config_mutation`/`destructive` actions require approval.
|
||||
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
|
||||
- **Read state**: prefer MCP tools, then files, then shell. Examples:
|
||||
`homelab whoami`, `homelab list`, `homelab status`, `homelab logs caddy`.
|
||||
- **Cross-host actions** (caddy reload, pct exec, etc.): use the `homelab`
|
||||
CLI — it resolves hostname → mesh address → ssh / pct path for you. Direct
|
||||
SSH still works; the CLI just removes the lookup burden.
|
||||
- **Secrets**: never hardcode. Call `homelab secret <name>` to decrypt on
|
||||
demand using the per-client age key at `/etc/age/key.txt`. Secrets ARE
|
||||
available in this system — `list_my_secrets()` (MCP) shows what you can
|
||||
decrypt.
|
||||
- **Mutations** (restart, edit configs, etc.): the `homelab` CLI's mutating
|
||||
subcommands ask for confirmation. For ad-hoc work, SSH and edit directly —
|
||||
but commit changes that touch tracked configs (caddy, gitea custom,
|
||||
artifacto, mule-image, etc.; see `infrastructure/auto-deploy.md`).
|
||||
- **Wiki updates**: same-session rule applies to any meaningful state change
|
||||
this client makes.
|
||||
|
||||
## 6. Communication mode
|
||||
## 6. When in doubt
|
||||
|
||||
Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's
|
||||
terse-communication standard — drop filler, keep substance, use fragments.
|
||||
|
||||
## 7. Auto-setup mechanism
|
||||
|
||||
The homelab-context repo ships tooling that gets automatically installed
|
||||
on every client after `git pull`. This is handled by `tools/post-pull.sh`
|
||||
(replaces the raw git pull in the sync timer) which runs any script matching
|
||||
`tools/*.setup.sh` after pull.
|
||||
|
||||
Currently auto-setup:
|
||||
- **Caveman + templates** (`tools/setup-caveman.sh`): Installs Caveman npm
|
||||
package, wrapper scripts, and compact output templates for token-efficient
|
||||
CLI output. Wrapper at `~/bin/caveman_wrapper.sh`.
|
||||
- **Nomos agent persona** (`tools/setup-nomos-soul.sh`): Provisions
|
||||
`~/.nomos/SOUL.md` from `NOMOS.md` on Nomos agents. This ensures every
|
||||
Nomos agent follows the canonical homelab persona (token efficiency, source
|
||||
of truth hierarchy). No-op on non-Nomos agents.
|
||||
|
||||
To add a new auto-setup, create `tools/<name>.setup.sh` in the repo,
|
||||
commit and push. All enrolled clients pick it up within 5 minutes.
|
||||
|
||||
To trigger sync manually: `sudo homelab sync` or wait for the 5-min timer.
|
||||
|
||||
## 8. When in doubt
|
||||
|
||||
Use MCP tools: `search_knowledge <query>` for narrative context,
|
||||
`get_entity <slug>` for structured data, `get_entity_knowledge <slug>` for
|
||||
everything linked to an entity. The clone is the fallback; MCP is the index.
|
||||
Run `homelab mcp search_docs <query>` or `homelab mcp get_host <name>`.
|
||||
The clone is the fallback; MCP is the index.
|
||||
|
||||
139
CLIENTS.md
139
CLIENTS.md
@@ -1,139 +0,0 @@
|
||||
# Oikos — client guide
|
||||
|
||||
If you are a homelab machine, this is what Oikos is and what it gives you.
|
||||
If you are an AI agent running on this machine, also read [AGENTS.md](AGENTS.md).
|
||||
|
||||
## What is Oikos?
|
||||
|
||||
Oikos is the agentic operating system for the **hubris** Proxmox homelab. It
|
||||
observes state, classifies actions against policy, executes approved
|
||||
procedures, learns from outcomes, and notifies the operator. It runs as a
|
||||
Docker stack on mac-mini and exposes an MCP server + REST API.
|
||||
|
||||
## What Oikos provides
|
||||
|
||||
| Capability | How you access it |
|
||||
|------------|-------------------|
|
||||
| Entity query (topology, blast radius) | MCP `get_entity`, `get_blast_radius` |
|
||||
| Full-text knowledge search | MCP `search_knowledge` |
|
||||
| Service status + logs | MCP `get_service_status`, `tail_log` |
|
||||
| LXC inventory + state | MCP `list_lxcs`, `get_lxc_state` |
|
||||
| Context cards | MCP `explain` |
|
||||
| Pre-flight risk classification | MCP `preflight` |
|
||||
| Change history | MCP `get_change_history` |
|
||||
| State snapshot (health, disk, drift) | MCP `get_state_snapshot` |
|
||||
| Secrets (Infisical) | REST API + `oikos secret` CLI |
|
||||
| Approval tokens | Matrix via notifier |
|
||||
|
||||
All MCP tools are read-only. Mutations use the `homelab` CLI with operator
|
||||
approval.
|
||||
|
||||
## Enrollment
|
||||
|
||||
Thin client model — no git clone, no sync timer. `bootstrap.sh` fetches only
|
||||
the agent orientation files and tooling from the raw Gitea URL, then enrolls
|
||||
via the Oikos API.
|
||||
|
||||
To enroll:
|
||||
```bash
|
||||
# Run from any machine with mesh connectivity
|
||||
curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh | sudo bash
|
||||
|
||||
# Or with optional tooling:
|
||||
curl ... | sudo bash -s -- --with-mcp # wire Claude's MCP config
|
||||
curl ... | sudo bash -s -- --with-nomos # install Goose + Nomos
|
||||
```
|
||||
|
||||
This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
|
||||
1. Validates the entity exists in DB (planned or provisioning state)
|
||||
2. Validates mesh IP against expected subnets
|
||||
3. Generates an age keypair and delivers it to the client
|
||||
4. Creates an Infisical machine identity
|
||||
5. Transitions the entity to provisioning state
|
||||
|
||||
## After enrollment
|
||||
|
||||
### What changes on your machine
|
||||
|
||||
- `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md)
|
||||
- `/opt/homelab/tools/` — tooling scripts (caveman, nomos-soul)
|
||||
- `/etc/age/key.txt` — age private key for SOPS decryption (fallback)
|
||||
- `/etc/infisical/identity` — Infisical machine identity (primary secrets)
|
||||
- Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates
|
||||
|
||||
### What's NOT on your machine
|
||||
|
||||
- No git clone of the full repo
|
||||
- No `git pull` sync timer
|
||||
- No `bin/homelab` CLI (replaced by MCP tools + API)
|
||||
- No `.sops.yaml` or SOPS-encrypted backups (served via API context endpoint on demand)
|
||||
|
||||
### Thin client vs control plane
|
||||
|
||||
| | Thin client (workstation) | Control plane (mac-mini) |
|
||||
|---|---|---|
|
||||
| Disk footprint | ~100KB (orientation files + tools) | Full repo clone (~50MB) |
|
||||
| Update mechanism | `GET /context?since=` poll | Git pull + post-pull.sh |
|
||||
| Source of truth | DB via MCP | DB + local seeds + archive |
|
||||
| Secrets access | Infisical (primary), age/SOPS served via API (fallback) | Infisical + local SOPS files |
|
||||
|
||||
### Your identity
|
||||
|
||||
Your identity in the homelab is defined in `inventory.yaml`. Run `hostname`
|
||||
(Linux) or `scutil --get LocalHostName` (macOS), then look up your entry.
|
||||
It tells you your role, what services you host, what's mounted, and your
|
||||
mesh address.
|
||||
|
||||
### Source of truth hierarchy
|
||||
|
||||
1. **Postgres database** (runtime) — authoritative for entities, knowledge,
|
||||
signals, ledger. Query via MCP or REST API.
|
||||
2. **Context poller** — agent files and tooling fetched via API deltas every
|
||||
5 minutes.
|
||||
3. **Never guess.** If data is missing, query MCP. If MCP is down, grep the
|
||||
local `/opt/homelab/` files.
|
||||
|
||||
## The context poller
|
||||
|
||||
Every 5 minutes, launchd (macOS) or systemd (Linux) hits:
|
||||
|
||||
```
|
||||
GET /api/v1/clients/ws:{hostname}/context?since={last_timestamp}
|
||||
```
|
||||
|
||||
The API returns which agent files, tools, and SOPS config changed since the
|
||||
last poll. Only changed files are downloaded. This replaces the old
|
||||
`git pull` with a lightweight HTTP delta.
|
||||
|
||||
To trigger manually: run `/opt/homelab/tools/context-poller.sh`.
|
||||
|
||||
## Making changes
|
||||
|
||||
- **Read state**: use MCP tools or the API
|
||||
- **Mutate state** (restart, edit config, deploy): classify the action against
|
||||
policy (query `preflight` MCP tool):
|
||||
- `read_only` / `reversible_low` — execute directly
|
||||
- `config_mutation` / `destructive` — request operator approval via
|
||||
`POST /api/v1/entities/{slug}/activate` (or equivalent lifecycle endpoint)
|
||||
- **Secrets**: use Infisical (primary) or SOPS (fallback). Never hardcode.
|
||||
- **Knowledge**: if you observe a discrepancy between docs and live state,
|
||||
update the DB via the API in the same session.
|
||||
|
||||
## MCP endpoint
|
||||
|
||||
```
|
||||
https://mcp.hubris.network/mcp
|
||||
```
|
||||
|
||||
Available tools are listed in [AGENTS.md](AGENTS.md#3-the-mcp-server).
|
||||
|
||||
## Communication mode
|
||||
|
||||
Apply [.agents/shared/caveman.md](.agents/shared/caveman.md) — terse,
|
||||
fragment-heavy communication. Drop filler, keep substance.
|
||||
|
||||
## Related
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — full agent orientation (read this first)
|
||||
- [OIKOS.md](.agents/OIKOS.md) — operating model, OODA loop, risk classes
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — developer guide for the Oikos codebase
|
||||
225
CONTRIBUTING.md
225
CONTRIBUTING.md
@@ -1,155 +1,98 @@
|
||||
# Contributing to Oikos
|
||||
# Contributing to the Homelab Wiki
|
||||
|
||||
Developer guide for the Oikos codebase. If you are a homelab client consuming
|
||||
Oikos, see [CLIENTS.md](CLIENTS.md). If you are an AI agent working on the
|
||||
repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
|
||||
## Voice
|
||||
|
||||
## Dev setup
|
||||
Concise, technical, sysadmin-to-sysadmin. No marketing prose, no exclamation marks.
|
||||
|
||||
- **Go 1.26+** (see `go.mod` for pinned version)
|
||||
- **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16`
|
||||
- **Docker** for the full dev stack
|
||||
## Page templates
|
||||
|
||||
```bash
|
||||
# Start dependencies (Postgres + Redis)
|
||||
docker compose --profile dev up -d
|
||||
### Container page (`containers/<id>-<name>.md`)
|
||||
|
||||
# Run all tests
|
||||
make test
|
||||
```markdown
|
||||
# <id> — `<name>`
|
||||
|
||||
# Run integration tests (needs compose Postgres)
|
||||
make test-db
|
||||
One-sentence purpose.
|
||||
|
||||
# Build the binary
|
||||
make build
|
||||
```
|
||||
## At a glance
|
||||
- **Hostname:** `<name>`
|
||||
- **IP:** `192.168.8.x`
|
||||
- **Privilege:** privileged | unprivileged
|
||||
- **Resources:** N cores / M GiB RAM / D GiB rootfs
|
||||
- **Mounts:** `/mnt/library` ↔ `/mnt/library` (if any)
|
||||
- **Public hostname:** `<sub>.hubris.network` (if proxied)
|
||||
|
||||
## Project structure
|
||||
## Role
|
||||
What it does, what it talks to.
|
||||
|
||||
```
|
||||
cmd/oikos/ Single-binary entry point
|
||||
cmd/nomos/ Nomos MCP client gateway
|
||||
internal/ All Go packages
|
||||
httpapi/ REST + MCP server (OpenAPI-generated)
|
||||
mcp/ MCP tool implementations
|
||||
db/ Connection pool, migrations, seeds, sqlc queries
|
||||
scheduler/ Observe loop, probes, signals
|
||||
actuator/ SSH execution
|
||||
learning/ Pattern recognition, anomaly detection
|
||||
notifier/ Matrix notifications, approval tokens
|
||||
policy/ Risk classifier
|
||||
secrets/ Infisical + SOPS backend
|
||||
domain/ Core types: entities, approvals, signals, patterns
|
||||
ontology/ Type hierarchy, relationship validation
|
||||
knowledge/ Knowledge YAML seed ingestion
|
||||
api/openapi.yaml API contract — the source of truth for endpoints
|
||||
migrations/ Forward-only SQL migrations (TimescaleDB)
|
||||
seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge
|
||||
compose/ Dockerfiles + Caddy config
|
||||
scripts/ Deploy, watchdog, rollback
|
||||
nomos/ Nomos config, persona, skills
|
||||
.agents/ Agent instruction files + skills
|
||||
plans/ Design documents
|
||||
docs/adr/ Architecture decision records
|
||||
```
|
||||
## Service / port map
|
||||
| Service | Listen | Notes |
|
||||
|
||||
## Commands
|
||||
## Storage / config paths
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `make build` | Build `oikos` binary |
|
||||
| `make test` | Run all tests with race detection |
|
||||
| `make test-db` | Run integration tests against compose Postgres |
|
||||
| `make lint` | `go vet` + `golangci-lint` |
|
||||
| `make generate` | Regenerate OpenAPI + sqlc code |
|
||||
| `make generate-check` | CI drift guard — fail if generated code is stale |
|
||||
| `make migrate` | Apply DB migrations |
|
||||
| `make seed` | Ingest seeds into DB |
|
||||
| `make export` | Export DB state to YAML seeds |
|
||||
| `make dev` | Start compose dev stack |
|
||||
| `make clean` | Remove binary + test cache |
|
||||
|
||||
## Conventions
|
||||
|
||||
### APIs are OpenAPI-first
|
||||
|
||||
The REST API is defined in `api/openapi.yaml`. Server code is generated with
|
||||
`oapi-codegen` into `internal/httpapi/gen/`. To add an endpoint:
|
||||
|
||||
1. Add the path + schema to `api/openapi.yaml`
|
||||
2. Run `make generate`
|
||||
3. Implement the handler in `internal/httpapi/impl.go`
|
||||
4. Add tests in `internal/httpapi/api_test.go`
|
||||
|
||||
Never hand-edit `internal/httpapi/gen/api.gen.go`.
|
||||
|
||||
### Database access is sqlc-first
|
||||
|
||||
SQL queries live in `internal/db/queries/*.sql`. Go code is generated with
|
||||
`sqlc` into `internal/db/sqlcgen/`. Config in `sqlc.yaml`.
|
||||
|
||||
- Queries target pgx/v5 with UUID + timestamptz overrides
|
||||
- Never hand-edit generated sqlc code
|
||||
|
||||
### Migrations are forward-only
|
||||
|
||||
SQL migrations live in `migrations/` as `NNN_name.up.sql`. There are no down
|
||||
migrations (see [ADR 0008](docs/adr/0008-forward-only-migrations.md)).
|
||||
Migrations are idempotent where possible (`IF NOT EXISTS`, `DO $$` blocks).
|
||||
|
||||
To add a migration:
|
||||
|
||||
1. Create `migrations/NNN_name.up.sql` with the next sequence number
|
||||
2. Write the DDL
|
||||
3. Run `make migrate` to apply
|
||||
|
||||
### Seeds are DB-generated
|
||||
|
||||
`seeds/*.yaml` are the bootstrap files used by `oikos seed`. After making
|
||||
changes via the API, run `make export` to regenerate the seed files. These
|
||||
files are version-controlled and serve as DR fallback.
|
||||
|
||||
### Writing style
|
||||
|
||||
Follow [.agents/shared/writing-style.md](.agents/shared/writing-style.md).
|
||||
Documentation is reference prose, not marketing. Banned vocabulary includes
|
||||
"robust", "seamless", "leverage", "utilize", "delve", "cutting-edge".
|
||||
|
||||
### Risk classification
|
||||
|
||||
Every mutation is classified against `seeds/policy.yaml` before execution.
|
||||
Four risk classes: `read_only`, `reversible_low`, `config_mutation`,
|
||||
`destructive`. The classifier can only lower autonomy relative to policy,
|
||||
never raise it. When in doubt, escalate.
|
||||
|
||||
## CI
|
||||
|
||||
Gitea Actions runs on push to `main` and pull requests (`ci.yml`):
|
||||
|
||||
1. `go vet` + `golangci-lint` + `govulncheck`
|
||||
2. Generated code drift check (`make generate-check`)
|
||||
3. Build (`go build ./...`)
|
||||
4. Test with race detector + coverage
|
||||
5. Docker build verification (no push)
|
||||
|
||||
Coverage gates: policy + learning packages ≥ 80%, others ≥ 60%.
|
||||
|
||||
## PR workflow
|
||||
|
||||
1. Create a branch from `main`
|
||||
2. Make changes, write tests
|
||||
3. Run `make lint test generate-check`
|
||||
4. Commit with a message following: problem → change → risk → verification
|
||||
5. Push to Gitea; CI gates PRs on green
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets are managed by Infisical (primary) with SOPS as DR fallback. Never
|
||||
hardcode secrets. Use environment variables from `.env` for local dev.
|
||||
The `.env` and `.infisical-credentials` files are gitignored.
|
||||
## Auto-deploy
|
||||
(if any) — link to [auto-deploy](../infrastructure/auto-deploy.md)
|
||||
|
||||
## Related
|
||||
- [Caddy](121-caddy.md) (if proxied)
|
||||
- [DNS](../infrastructure/dns.md) (if has subdomain)
|
||||
- [Authentik](124-authentik.md) (if SSO)
|
||||
- ...
|
||||
|
||||
- [OIKOS.md](.agents/OIKOS.md) — operating model, OODA loop, ontology
|
||||
- [CLIENTS.md](CLIENTS.md) — for homelab clients consuming Oikos
|
||||
- [docs/adr/](docs/adr/) — architecture decision records
|
||||
## Changelog
|
||||
### YYYY-MM-DD — short title
|
||||
What changed, why, link to investigation if any.
|
||||
```
|
||||
|
||||
### Cross-cutting page (`infrastructure/<topic>.md`)
|
||||
|
||||
```markdown
|
||||
# <Topic>
|
||||
|
||||
One-sentence summary.
|
||||
|
||||
## Why
|
||||
Design rationale — what it replaces, what it solves.
|
||||
|
||||
## Components
|
||||
Where it runs, what files matter.
|
||||
|
||||
## How to apply / use
|
||||
Recipes.
|
||||
|
||||
## Gotchas
|
||||
|
||||
## Related
|
||||
Links to nodes that host or depend on this.
|
||||
|
||||
## Changelog
|
||||
```
|
||||
|
||||
### Investigation (`investigations/YYYY-MM-DD-slug.md`)
|
||||
|
||||
```markdown
|
||||
# YYYY-MM-DD — <title>
|
||||
|
||||
## Summary
|
||||
1-3 sentences.
|
||||
|
||||
## Timeline
|
||||
|
||||
## Root cause
|
||||
|
||||
## Mitigations applied
|
||||
|
||||
## Open questions
|
||||
```
|
||||
|
||||
## Linking discipline
|
||||
|
||||
- Every container page links to every cross-cutting page it participates in.
|
||||
- Every cross-cutting page lists the nodes that participate.
|
||||
- Every investigation links to the nodes it implicates *and* gets back-linked from each node's changelog.
|
||||
|
||||
## Changelog hygiene
|
||||
|
||||
- Reverse-chronological (newest first).
|
||||
- One entry per discrete change, even if you make several in one day.
|
||||
- If a change spans nodes, repeat the entry on each affected page (different perspective is fine).
|
||||
- Don't rewrite history — entries are append-only. Mistakes get a follow-up entry that supersedes them.
|
||||
|
||||
50
Makefile
50
Makefile
@@ -1,50 +0,0 @@
|
||||
.PHONY: build test test-db lint generate generate-check dev migrate seed export clean tidy
|
||||
|
||||
BINARY := oikos
|
||||
GO ?= go
|
||||
|
||||
build:
|
||||
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
|
||||
|
||||
test:
|
||||
$(GO) test -race -cover ./...
|
||||
|
||||
# Integration tests against the compose Postgres (starts it if needed)
|
||||
test-db:
|
||||
docker compose up -d postgres
|
||||
@sleep 3
|
||||
OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \
|
||||
$(GO) test -race -count=1 ./internal/db/ ./internal/httpapi/ ./internal/mcp/
|
||||
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed, skipping"
|
||||
|
||||
generate:
|
||||
$(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
|
||||
-config api/codegen.yaml api/openapi.yaml
|
||||
$(GO) run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.29.0 generate
|
||||
|
||||
# CI drift guard: regenerate and fail if the committed output changed.
|
||||
generate-check: generate
|
||||
@git diff --exit-code -- internal/httpapi/gen internal/db/sqlcgen \
|
||||
|| (echo "generated code is stale — run 'make generate' and commit" && exit 1)
|
||||
|
||||
migrate:
|
||||
$(GO) run ./cmd/oikos migrate
|
||||
|
||||
seed:
|
||||
$(GO) run ./cmd/oikos seed
|
||||
|
||||
export:
|
||||
$(GO) run ./cmd/oikos export
|
||||
|
||||
dev:
|
||||
docker compose --profile dev up -d
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
$(GO) clean -testcache
|
||||
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
181
README.md
181
README.md
@@ -1,136 +1,77 @@
|
||||
# Oikos
|
||||
# Homelab Wiki — `hubris`
|
||||
|
||||
Agentic homelab operating system written in Go. Single binary (`cmd/oikos`),
|
||||
Docker-deployed on mac-mini, with a standalone Nomos MCP agent gateway
|
||||
(`cmd/nomos`). Manages the **hubris** Proxmox homelab autonomously — observes
|
||||
state, classifies actions against policy, executes approved procedures over SSH,
|
||||
learns from outcomes, and escalates when uncertain.
|
||||
Living documentation for the **hubris** Proxmox homelab. Every node, every cross-cutting system, and every meaningful incident is its own page; pages are linked so you can start anywhere and walk the graph.
|
||||
|
||||
**For agents running on enrolled clients:** start with [AGENTS.md](AGENTS.md).
|
||||
**For client machines:** see [CLIENTS.md](CLIENTS.md).
|
||||
**For developers:** see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
> Last refreshed against live state: **2026-04-28**.
|
||||
|
||||
## Quick start
|
||||
## Map
|
||||
|
||||
```bash
|
||||
# Dev stack (postgres + api + scheduler + notifier)
|
||||
docker compose --profile dev up -d
|
||||
### Hosts
|
||||
- [`hubris`](hosts/hubris.md) — single Proxmox VE node, GMKtec NucBox M6 Ultra, `192.168.8.77`
|
||||
|
||||
# Full stack (adds Nomos agent gateway)
|
||||
docker compose --profile full up -d
|
||||
### VMs
|
||||
- [100 — `zimaos`](vms/100-zimaos.md) — ZimaOS 1.6.1, NAS frontend (evaluation)
|
||||
- [108 — `haos-16.3`](vms/108-haos.md) — Home Assistant OS
|
||||
|
||||
# Build standalone binary
|
||||
go build -o bin/oikos -tags timetzdata ./cmd/oikos
|
||||
### LXC containers
|
||||
See the full table in [`containers/index.md`](containers/index.md). Quick links:
|
||||
|
||||
# Run all roles in one process (dev mode)
|
||||
OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \
|
||||
go run ./cmd/oikos all
|
||||
```
|
||||
| ID | Name | IP | Role |
|
||||
| --- | ---------------- | --------------- | --------------------------------------------- |
|
||||
| 101 | [jellyfin](containers/101-jellyfin.md) | 192.168.8.206 | Media server |
|
||||
| 102 | [nfs-export](containers/102-nfs-export.md) | 192.168.8.200 | NFSv4 re-export of /mnt/library for ZimaOS |
|
||||
| 103 | [paperless](containers/103-paperless.md) | 192.168.8.130 | Document mgmt |
|
||||
| 104 | [gitea](containers/104-gitea.md) | 192.168.8.121 | Git server |
|
||||
| 105 | [apps](containers/105-apps.md) | 192.168.8.205 | Docker host (Artifacto / Booklore / PlantUML / Portainer / WriteFreely) |
|
||||
| 114 | [nextcloud](containers/114-nextcloud.md) | 192.168.8.224 | Personal cloud |
|
||||
| 118 | [elementsynapse](containers/118-elementsynapse.md) | 192.168.8.239 | Matrix Synapse |
|
||||
| 119 | [sophia](containers/119-sophia.md) | 192.168.8.157 | Sophia |
|
||||
| 120 | [mule-images](containers/120-mule-images.md) | 192.168.8.136 | Mule-image / mulita photos |
|
||||
| 121 | [caddy](containers/121-caddy.md) | 192.168.8.175 | Reverse proxy |
|
||||
| 122 | [arriman](containers/122-arriman.md) | 192.168.8.132 | Docker host (\*arr stack) |
|
||||
| 123 | [claudio-bot](containers/123-claudio-bot.md) | 192.168.8.230 | Matrix control plane |
|
||||
| 124 | [authentik](containers/124-authentik.md) | 192.168.8.180 | SSO + split-horizon DNS |
|
||||
| 126 | [plato](containers/126-plato.md) | 192.168.8.190 | Plato (notes/discovery workspace) |
|
||||
|
||||
## Architecture
|
||||
### Cross-cutting infrastructure
|
||||
- [DNS — split-horizon](infrastructure/dns.md)
|
||||
- [Ingress — Caddy + VPS traefik](infrastructure/ingress.md)
|
||||
- [Mesh — Tailscale → Netbird migration](infrastructure/mesh.md)
|
||||
- [Monitoring — claudio-monitor](infrastructure/monitoring.md)
|
||||
- [Media permissions — `media` GID 10000](infrastructure/media-permissions.md)
|
||||
- [SSH access](infrastructure/ssh-access.md)
|
||||
- [Backups — restic on external drive (disabled)](infrastructure/backups.md)
|
||||
- [Auto-deploy — gitea-webhook pipelines](infrastructure/auto-deploy.md)
|
||||
- [VPS hardening — IONOS / netbird control plane](infrastructure/vps-hardening.md)
|
||||
- [Homelab context distribution](infrastructure/homelab-context.md) — cross-client `/opt/homelab-context` + MCP + secrets-issuance
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ mac-mini (Docker) │
|
||||
│ │
|
||||
Workstation ─── │ nomos (8092) ──MCP── api (8090) │
|
||||
(mesh) │ MCP gateway REST + MCP │
|
||||
│ │
|
||||
│ scheduler ── notifier ── postgres │
|
||||
│ (observe) (Matrix) (Timescale)│
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
### Investigations
|
||||
Time-stamped incident notes / experiments in [`investigations/`](investigations/index.md).
|
||||
|
||||
| Component | Port | Role |
|
||||
|-----------|------|------|
|
||||
| `oikos api` | 8090 | REST API + MCP server (15 tools) |
|
||||
| `oikos scheduler` | — | Probe runner, signal lifecycle, metrics |
|
||||
| `oikos notifier` | — | Approval tokens, Matrix alerts |
|
||||
| `nomos serve` | 8092 | MCP client gateway, query routing |
|
||||
### Operations
|
||||
- [Command cheatsheet](operations/commands.md)
|
||||
- [Agent enrollment](operations/agent-enrollment.md) — bootstrap a new client (workstation, LXC, VM) into the homelab context system
|
||||
|
||||
## Phases
|
||||
## Conventions
|
||||
|
||||
| Phase | Status | Description |
|
||||
|-------|--------|-------------|
|
||||
| 1 — Ontology + DB | ✅ | TimescaleDB, migrations, seeds, blast_radius |
|
||||
| 2 — API | ✅ | OpenAPI-first REST + MCP, auth, SSE, audit |
|
||||
| 3 — Control loop | ✅ | Scheduler, actuator, learning, classifier, notifier |
|
||||
| 4 — Nomos agent | ✅ | Standalone MCP client gateway, agent activity |
|
||||
| 5 — Secrets | ✅ | Infisical backend + SOPS fallback, rotation runbooks |
|
||||
| 6 — Deploy | ✅ | CI pipeline, cutover checklist, watchdog, rollback |
|
||||
- **Each node page** ends with a `## Changelog` section. Reverse-chronological. Entry format:
|
||||
```
|
||||
### YYYY-MM-DD — short title
|
||||
one or two lines on what changed and why.
|
||||
```
|
||||
- **Cross-linking is mandatory.** If a page references another node or system, link to it. Treat orphans as a bug.
|
||||
- **Live state wins.** When something here disagrees with `pct config` / `docker inspect` / running config, fix the wiki *and* note the change in the relevant changelog.
|
||||
- **Tracked configs.** A node whose config lives in a Gitea repo (Caddy, Gitea customizations, Artifacto, mule-image, claudio-bot) is auto-deployed via webhook — see [auto-deploy](infrastructure/auto-deploy.md). Edits there must be pushed, not left local.
|
||||
- **No secrets.** This is a private repo on `git.hubris.network`, but still: paths to secret files are fine, secret values are not.
|
||||
|
||||
Full plan: [plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md).
|
||||
## Maintaining this wiki
|
||||
|
||||
## Operations
|
||||
When you change a node:
|
||||
1. Update the relevant page (config snapshot, ports, mounts).
|
||||
2. Add a changelog entry at the bottom of that page.
|
||||
3. If the change touches a cross-cutting system (DNS, Caddy, Authentik, mesh), update *that* page too and link it from the changelog entry.
|
||||
4. If it's an incident, add an entry to [`investigations/`](investigations/index.md).
|
||||
|
||||
### API endpoints
|
||||
## See also
|
||||
|
||||
```bash
|
||||
curl http://localhost:8090/api/v1/entities?type=service # fleet
|
||||
curl http://localhost:8090/api/v1/health # fleet health
|
||||
curl http://localhost:8090/api/v1/agent-activity # agent log
|
||||
```
|
||||
|
||||
### Nomos queries
|
||||
|
||||
```bash
|
||||
# Structured tool call
|
||||
curl -X POST localhost:8092/query -H "Content-Type: application/json" \
|
||||
-d '{"tool":"get_blast_radius","args":{"entity_id":"service:authentik"}}'
|
||||
|
||||
# Natural language
|
||||
curl -X POST localhost:8092/query -H "Content-Type: application/json" \
|
||||
-d '{"query":"what depends on authentik?"}'
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
oikos migrate # apply DB migrations
|
||||
oikos seed # ingest ontology/inventory/policy seeds
|
||||
oikos export # export DB state to YAML
|
||||
oikos api # serve REST + MCP
|
||||
oikos scheduler # run observe loop
|
||||
oikos notifier # run notification loop
|
||||
oikos all # all roles in one process
|
||||
oikos secret list # enumerate SOPS secrets
|
||||
oikos secret migrate # SOPS → Infisical
|
||||
```
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
cmd/oikos/ Go entry point — single binary
|
||||
cmd/nomos/ Nomos MCP client gateway
|
||||
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning,
|
||||
notifier, policy, secrets, db, config, ontology, domain,
|
||||
knowledge)
|
||||
api/openapi.yaml API contract (OpenAPI 3.1)
|
||||
migrations/ Forward-only SQL migrations (TimescaleDB)
|
||||
seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge)
|
||||
compose/ Dockerfiles + Caddy config
|
||||
scripts/ Deploy, watchdog, verification, rollback
|
||||
nomos/ Nomos config, persona, skills
|
||||
.agents/ Agent instruction files, shared conventions, skills
|
||||
archive/ Historical reference (legacy wiki, plans, SOPS backups)
|
||||
plans/ Design documents (active + done)
|
||||
docs/adr/ Architecture decision records
|
||||
```
|
||||
|
||||
## For agents
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for the full orientation. Quick reference:
|
||||
|
||||
- **Source of truth:** DB (runtime) then seeds (bootstrap). Old wiki is
|
||||
archived at `archive/knowledge/` — use MCP `search_knowledge` instead.
|
||||
- **Mutations:** classify against policy, request approval for
|
||||
`destructive`/`config_mutation`
|
||||
- **Secrets:** Infisical (primary) or SOPS (fallback) — never hardcode
|
||||
|
||||
## Related
|
||||
|
||||
- [OIKOS.md](.agents/OIKOS.md) — operating model, OODA loop, ontology
|
||||
- [CLIENTS.md](CLIENTS.md) — client onboarding guide
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — developer guide
|
||||
- [plans/](plans/) — design documents and cutover checklist
|
||||
- [docs/adr/](docs/adr/) — architecture decision records
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — page templates and tone
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# oapi-codegen config — `make generate` regenerates internal/httpapi/gen.
|
||||
package: gen
|
||||
output: internal/httpapi/gen/api.gen.go
|
||||
generate:
|
||||
models: true
|
||||
chi-server: true
|
||||
strict-server: true
|
||||
embedded-spec: true
|
||||
3303
api/openapi.yaml
3303
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
# Redocly lint config for api/openapi.yaml (CI runs: redocly lint api/openapi.yaml)
|
||||
extends:
|
||||
- recommended
|
||||
rules:
|
||||
# Every operation declares `default` → RFC 9457 problem+json instead of
|
||||
# enumerating each 4XX (plan R3-3); oapi-codegen handles `default` fine.
|
||||
operation-4xx-response: off
|
||||
@@ -1,277 +0,0 @@
|
||||
# Plan: Migrate library SSD to ludo-mini + Proxmox gaming/media server
|
||||
|
||||
## Goal
|
||||
|
||||
Split the homelab into two Proxmox hosts:
|
||||
|
||||
| Host | Role | Storage |
|
||||
|------|------|---------|
|
||||
| **hubris** | Core services (reverse-proxy, SSO, Matrix, git, documents, HA) | SSD 1 — boot + LXC rootfs (unchanged) |
|
||||
| **ludo-mini** | Gaming server + media/library services | SSD 2 — Samsung 990 EVO Plus 4 TB (moved from hubris) |
|
||||
|
||||
The library SSD physically moves from hubris to ludo-mini. hubris LXCs that still need `/mnt/library` access it over NFS from ludo-mini.
|
||||
|
||||
## Current state
|
||||
|
||||
### hubris hardware
|
||||
- GMKtec NucBox M6 Ultra — AMD Ryzen 5 7640HS, 12 vCPU, ~28 GiB RAM
|
||||
- 2× Samsung 990 EVO Plus NVMe:
|
||||
- nvme0: `local` (95G) + `local-lvm` (856G) — boot, ISOs, LXC rootfs
|
||||
- nvme1: `library` LVM (3.7T) — `/mnt/library` ext4 via `/dev/mapper/library-library`
|
||||
|
||||
### LXCs binding `/mnt/library` (host-level bind-mount)
|
||||
| ID | Name | Role | I/O profile |
|
||||
|----|------|------|-------------|
|
||||
| 101 | jellyfin | Media streaming | Read-heavy, sequential |
|
||||
| 103 | paperless | Document archive | Mixed, OCR writes |
|
||||
| 104 | gitea | Git server | Mixed, lots of small files |
|
||||
| 105 | apps | Docker (booklore, audiobookshelf, artifacto, MCP) | Mixed, depends on container |
|
||||
| 114 | nextcloud | File sync | Mixed, WebDAV |
|
||||
| 119 | sophia | Workshop | Low I/O |
|
||||
| 120 | mule-images | Photo management | Write-heavy (processing), iGPU |
|
||||
| 122 | arriman | *arr stack + downloads | Write-heavy (downloads) |
|
||||
| 126 | plato | App (sub-mount: `/mnt/library/documents/plato`) | Light |
|
||||
|
||||
### NFS export chain (for VM 100 zimaos)
|
||||
```
|
||||
/mnt/library (ext4, host) → bind-mount → LXC 102 (nfs-export) → NFSv4 → VM 100 (zimaos)
|
||||
```
|
||||
|
||||
### ludo-mini current
|
||||
- Linux workstation, wired Ethernet 2.5 Gbps, `192.168.178.181` (household LAN)
|
||||
- Runs Sunshine for game streaming
|
||||
- No Proxmox, no LVM config
|
||||
- Connected to SODOLA switch (same switch as hubris eno1)
|
||||
|
||||
### Network topology
|
||||
```
|
||||
Fritz!Box 7590 (192.168.178.1)
|
||||
└── SODOLA 2.5G switch
|
||||
├── hubris eno1 → vmbr1 (192.168.178.10)
|
||||
│ └── routes to vmbr0 (192.168.8.0/24) — all LXCs
|
||||
└── ludo-mini (192.168.178.181)
|
||||
```
|
||||
|
||||
hubris routes between `192.168.8.0/24` (vmbr0) and `192.168.178.0/24` (vmbr1). So LXCs can reach ludo-mini via hubris as a router.
|
||||
|
||||
## Key decisions
|
||||
|
||||
### 1. Service split — what moves, what stays
|
||||
|
||||
**Move to ludo-mini** (high I/O, benefits from data locality + GPU):
|
||||
- 101 jellyfin — media streaming, GPU transcoding
|
||||
- 120 mule-images — photo processing, iGPU passthrough
|
||||
- 122 arriman — *arr stack, downloads write to library
|
||||
|
||||
**Stay on hubris, NFS-mount library from ludo-mini:**
|
||||
- 103 paperless — documents, moderate I/O
|
||||
- 104 gitea — git repos (small files, some I/O sensitivity but acceptable over NFS)
|
||||
- 105 apps — Docker apps, mixed workloads
|
||||
- 114 nextcloud — file sync
|
||||
- 119 sophia — workshop, light use
|
||||
- 126 plato — app, light use
|
||||
- 100 zimaos — NAS frontend, already NFS-mounted
|
||||
|
||||
### 2. NFS architecture
|
||||
|
||||
Instead of changing every LXC's mount config, keep the bind-mount pattern on hubris:
|
||||
|
||||
```
|
||||
ludo-mini: /mnt/library (ext4, local NVMe)
|
||||
│
|
||||
└── NFSv4 export to 192.168.8.0/24
|
||||
│
|
||||
└── hubris host: NFS-mount at /mnt/library
|
||||
│
|
||||
└── LXCs: bind-mount /mnt/library (unchanged!)
|
||||
```
|
||||
|
||||
This is transparent to all hubris LXCs — no container config changes needed. Only the hubris host changes from ext4 local mount to NFS mount. The LXC bind-mounts "just work" because `/mnt/library` is still at the same path on the host.
|
||||
|
||||
### 3. Network — ludo-mini reachability from hubris LXCs
|
||||
|
||||
LXCs on `192.168.8.0/24` reach ludo-mini (`192.168.178.181`) through hubris routing:
|
||||
- `vmbr0` → hubris kernel routing → `vmbr1` → SODOLA → ludo-mini
|
||||
- Already works (IP forwarding enabled on hubris)
|
||||
|
||||
**Alternative (cleaner):** Add a secondary IP `192.168.8.x` on ludo-mini's physical interface so it's directly on the homelab subnet. This avoids the router hop and keeps NFS traffic off kernel forwarding path. Worth considering but not required.
|
||||
|
||||
### 4. Gaming on ludo-mini with Proxmox
|
||||
|
||||
ludo-mini runs Sunshine (game streaming). Under Proxmox:
|
||||
- **Option A:** Gaming VM with GPU passthrough — Sunshine + games in a VM, full GPU access
|
||||
- **Option B:** LXC with GPU device passthrough (`/dev/dri`) — lighter, shares kernel
|
||||
- **Option C:** Keep Sunshine on the Proxmox host itself (not recommended, but simplest)
|
||||
|
||||
Option A is the cleanest for isolation. Games need a full desktop environment and GPU drivers; a VM with GPU passthrough gives them that.
|
||||
|
||||
### 5. What about nfs-export (LXC 102)?
|
||||
|
||||
Currently exports `/mnt/library` to zimaos. After migration:
|
||||
- If zimaos stays on hubris and accesses library via host NFS → bind-mount → LXC 102, that's triple-hop (ludo-mini → NFS → hubris → bind-mount → LXC 102 → NFS → zimaos). Terrible.
|
||||
- Better: zimaos NFS-mounts directly from ludo-mini.
|
||||
- So LXC 102 gets decommissioned (or repurposed).
|
||||
- zimaos gets a new NFS mount pointing directly at ludo-mini.
|
||||
|
||||
## Migration phases
|
||||
|
||||
### Phase 1 — Preparation (no downtime)
|
||||
|
||||
1. **Document current state** on hubris:
|
||||
- `pct list` — full container inventory
|
||||
- `pct config <id>` for every library-mounting LXC
|
||||
- `cat /etc/fstab` — capture the library mount line
|
||||
- `df -h /mnt/library` — confirm space usage
|
||||
- `lsblk -f` — UUID, filesystem
|
||||
- Identify the exact NVMe device (`nvme1n1`)
|
||||
|
||||
2. **Pre-flight on ludo-mini:**
|
||||
- Confirm hardware: CPU, RAM, available M.2 slots, GPU model
|
||||
- Confirm it can take the Samsung 990 EVO Plus (M.2 NVMe, PCIe 4.0 x4)
|
||||
- Verify BIOS supports virtualization (VT-d/AMD-Vi for PCIe passthrough)
|
||||
- Check: does ludo-mini have a second drive for Proxmox OS? If not, we need to partition the library SSD for Proxmox boot + library LVM, which complicates things significantly
|
||||
|
||||
3. **Install Proxmox on ludo-mini:**
|
||||
- Download Proxmox VE 9.x ISO
|
||||
- Install to ludo-mini's system drive (NOT the library SSD)
|
||||
- Configure networking: bridge for Proxmox, IP on 192.168.178.x
|
||||
- Test: web UI accessible
|
||||
|
||||
4. **Prepare NFS server on ludo-mini Proxmox:**
|
||||
- Create NFS-export LXC (or serve from host — simpler for now)
|
||||
- Prepare `/etc/exports`: `192.168.8.0/24(rw,all_squash,anonuid=33,anongid=10000,no_subtree_check,sec=sys)`
|
||||
- Same squash params as current nfs-export LXC 102
|
||||
|
||||
### Phase 2 — Physical SSD move (planned downtime)
|
||||
|
||||
1. **Graceful shutdown on hubris:**
|
||||
- Stop all library-mounting LXCs (101, 103, 104, 105, 114, 119, 120, 122, 126)
|
||||
- Unmount `/mnt/library` on hubris host
|
||||
- Edit `/etc/fstab` to comment out the library mount line
|
||||
- Power off hubris
|
||||
|
||||
2. **Physical drive swap:**
|
||||
- Remove Samsung 990 EVO Plus (library SSD) from hubris
|
||||
- Install into ludo-mini M.2 slot
|
||||
- Power on ludo-mini
|
||||
|
||||
3. **Bring library online on ludo-mini:**
|
||||
- Detect the new NVMe device
|
||||
- If it's the whole device with LVM, activate the VG:
|
||||
```
|
||||
vgscan && vgchange -ay library
|
||||
mount /dev/mapper/library-library /mnt/library
|
||||
```
|
||||
- Add to `/etc/fstab` for auto-mount
|
||||
- Verify content: `ls /mnt/library` — same tree as before
|
||||
|
||||
4. **Start NFS export on ludo-mini:**
|
||||
- `exportfs -ra`
|
||||
- Verify: `showmount -e <ludo-mini-ip>`
|
||||
|
||||
### Phase 3 — Reconnect hubris LXCs
|
||||
|
||||
1. **Power on hubris** (without library SSD — it'll boot fine, just won't mount library)
|
||||
|
||||
2. **Mount NFS on hubris host:**
|
||||
- Install `nfs-common` if not present
|
||||
- Add to `/etc/fstab`:
|
||||
```
|
||||
192.168.178.181:/mnt/library /mnt/library nfs rw,vers=4,soft,timeo=30,retrans=3 0 0
|
||||
```
|
||||
Use `soft` to prevent hangs if ludo-mini is down; `hard` with `intr` is safer for data integrity but can block processes.
|
||||
- `mount /mnt/library`
|
||||
- **Verify permissions:** `ls -la /mnt/library` — should show `www-data:media` ownership for shared subtrees (same uid 33, gid 10000). The NFS all_squash guarantees this.
|
||||
|
||||
3. **Start LXCs:**
|
||||
- Start the LXCs that stayed on hubris (103, 104, 105, 114, 119, 126)
|
||||
- Their bind-mounts should work — `/mnt/library` is populated via NFS
|
||||
- Verify each service: web UIs, git clone, document access
|
||||
|
||||
4. **Update DNS/Caddy** for services that moved:
|
||||
- If jellyfin, arr services moved to ludo-mini, update Caddyfile to point to ludo-mini IPs
|
||||
- Update DNS entries if needed
|
||||
|
||||
### Phase 4 — Migrate services to ludo-mini
|
||||
|
||||
1. **Create LXCs/VM on ludo-mini Proxmox:**
|
||||
- 101 jellyfin — privileged LXC, mount `/mnt/library`, add `media` group
|
||||
- 120 mule-images — privileged LXC, mount `/mnt/library` + `/dev/dri` passthrough
|
||||
- 122 arriman — privileged LXC, mount `/mnt/library`
|
||||
|
||||
2. **Migrate configs:**
|
||||
- Copy LXC configs from hubris (`/etc/pve/lxc/<id>.conf`) as templates
|
||||
- Adjust network (IPs on household subnet or Proxmox bridge)
|
||||
- Restore app data from backups or copy over NFS
|
||||
|
||||
3. **Gaming VM:**
|
||||
- Create VM with GPU passthrough
|
||||
- Pass through the dGPU for gaming performance
|
||||
- Install Sunshine + game libraries
|
||||
- Storage: VM disk on Proxmox storage, games on library SSD
|
||||
|
||||
4. **Update reverse proxy:**
|
||||
- Caddy on hubris (121): update backend IPs for jellyfin, jellyseerr, qbit, sab, mule-images → point to ludo-mini
|
||||
- Test: `media.hubris.network` serves from ludo-mini jellyfin
|
||||
|
||||
### Phase 5 — Cleanup
|
||||
|
||||
1. **Decommission nfs-export LXC 102** on hubris (no longer needed)
|
||||
2. **Update zimaos (100)** — change NFS mount from `192.168.8.200` → `192.168.178.181`
|
||||
3. **Remove old LXCs** from hubris (101, 120, 122) after confirming migration works
|
||||
4. **Update inventory.yaml:**
|
||||
- ludo-mini: `kind: proxmox-host`, add mounts/storage, add LXCs
|
||||
- Move services from hubris to ludo-mini
|
||||
- Remove nfs-export
|
||||
5. **Update DNS:** `nfs-export.hubris.network` → ludo-mini IP (or remove)
|
||||
6. **Run `homelab sync`** to propagate changes
|
||||
|
||||
## Open questions / unknowns
|
||||
|
||||
1. **Does ludo-mini have a second drive for Proxmox OS?** If not, we'd need to repartition the library SSD — carve out ~100 GB for Proxmox, then the rest for library LVM. This is risky (data loss if partitioning goes wrong) and requires a full backup first. **Alternative:** Buy a small SSD for ludo-mini's OS.
|
||||
|
||||
2. **What GPU does ludo-mini have?** Proxmox GPU passthrough requires IOMMU support and a GPU that doesn't have the reset bug. Need to check the exact GPU model.
|
||||
|
||||
3. **NFS performance for git (gitea)?** Git operations over NFS can be problematic (locking, stat() storms). Gitea bare repos at `/mnt/library/repos/*.git` might need testing. Worst case: move gitea's repo storage to local disk and keep `/mnt/library` for large file/LFS storage only.
|
||||
|
||||
4. **Media permission drift.** NFS `all_squash,anonuid=33,anongid=10000` ensures all writes from hubris LXCs (over NFS) and ludo-mini LXCs (local) land as `www-data:media`. This is the same squash currently used by nfs-export (102). Should be fine.
|
||||
|
||||
5. **ludo-mini network — add 192.168.8.x address?** Adding a secondary IP on ludo-mini's interface directly on the homelab subnet avoids routing through hubris for NFS traffic. Cleaner, but requires Proxmox bridge setup. Worth doing during Proxmox install.
|
||||
|
||||
6. **Sunshine migration.** Currently runs on ludo-mini bare metal. After Proxmox install, it needs to run in a VM. What happens to existing Sunshine configs, game libraries, save files? Need to preserve these during the Proxmox install.
|
||||
|
||||
7. **Backup before moving.** The library SSD holds 3.7 TB of irreplaceable data (documents, photos, repos). Restic backups are currently disabled. **Before physically moving the drive, verify the data is readable and consider doing one backup** — or at minimum, `rsync` critical directories.
|
||||
|
||||
## Files affected
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `/opt/homelab-context/inventory.yaml` | ludo-mini: workstation → proxmox-host; add LXCs, mounts; remove nfs-export; move service backends |
|
||||
| `/opt/homelab-context/hosts/hubris.md` | Remove library storage, add NFS mount note |
|
||||
| `/opt/homelab-context/hosts/ludo-mini.yaml` | Complete rewrite — Proxmox host, storage, tenants |
|
||||
| `/opt/homelab-context/containers/102-nfs-export.md` | Mark decommissioned |
|
||||
| `/opt/homelab-context/containers/index.md` | Move 101, 120, 122 to ludo-mini; remove 102 |
|
||||
| `/opt/homelab-context/infrastructure/dns.md` | Update nfs-export entry |
|
||||
| `/opt/homelab-context/infrastructure/media-permissions.md` | Note NFS squash from ludo-mini, not hubris |
|
||||
| hubris `/etc/fstab` | Replace ext4 mount with NFS mount |
|
||||
| ludo-mini `/etc/fstab` | Add library ext4 mount |
|
||||
| ludo-mini `/etc/exports` | Add NFS export config |
|
||||
| caddy (LXC 121) Caddyfile | Backend IPs for moved services |
|
||||
| DNS (LXC 107 Technitium) | Update entries for moved services |
|
||||
|
||||
## Validation checklist
|
||||
|
||||
- [ ] ludo-mini Proxmox web UI accessible
|
||||
- [ ] Library SSD detected and mountable on ludo-mini
|
||||
- [ ] NFS export from ludo-mini: `showmount -e <ip>` shows `/mnt/library`
|
||||
- [ ] hubris host NFS mount: `df -h /mnt/library` shows NFS, not ext4
|
||||
- [ ] hubris LXCs start and bind-mount /mnt/library (content visible)
|
||||
- [ ] gitea: `git clone` over SSH works, repos readable
|
||||
- [ ] paperless: document ingestion works, OCR processing
|
||||
- [ ] nextcloud: file sync, WebDAV
|
||||
- [ ] jellyfin: media plays from ludo-mini, transcoding works
|
||||
- [ ] arriman: downloads write to library, jellyfin picks up new media
|
||||
- [ ] mule-images: photo import and processing
|
||||
- [ ] zimaos: NFS mount from ludo-mini works, Files UI shows library
|
||||
- [ ] Sunshine: game streaming from ludo-mini VM works
|
||||
- [ ] All `*.hubris.network` services resolve and load through Caddy
|
||||
@@ -1,271 +0,0 @@
|
||||
# Homelab structure revision & improvement plan
|
||||
|
||||
## Goal
|
||||
|
||||
Identify structural issues in the current hubris homelab topology and propose an
|
||||
actionable improvement roadmap — DNS consolidation, monitoring gaps, backup
|
||||
recovery, mesh completion, resource rightsizing, and operational hygiene.
|
||||
|
||||
---
|
||||
|
||||
## Current state summary
|
||||
|
||||
| Dimension | Status |
|
||||
|-----------|--------|
|
||||
| Hypervisor | hubris (single-node Proxmox) — also does subnet routing (192.168.8.0/24) |
|
||||
| LXCs | 14 active, 1 retired (124), 1 new (107 dns) |
|
||||
| VMs | HAOS (108), ZimaOS (100) |
|
||||
| Workstations | mac-mini (macOS), republic-laptop, ludo-mini |
|
||||
| VPS | 1 IONOS box — netbird mgmt+signal+relay, traefik, authentik, coturn |
|
||||
| Switch | SODOLA 5-Port 2.5Gbit (L2, flat) |
|
||||
| Networking | 192.168.8.0/24 internal, fr!tz box main LAN via vmbr0→vmbr1 routing |
|
||||
| Mesh | Netbird + Tailscale (migrating), ~mixed state |
|
||||
| DNS | 3 sources: Technitium (CT 107), NetBird managed zone, public IONOS |
|
||||
| Backups | DISABLED since 2026-04-22 |
|
||||
| Monitoring | claudio-monitor (host health only, no apt/docker checks yet) |
|
||||
| Agent enrollment | 3/19 hosts enrolled (hubris, apps, republic-laptop) |
|
||||
|
||||
---
|
||||
|
||||
## Issues identified
|
||||
|
||||
### 1. Three overlapping DNS sources (highest risk)
|
||||
|
||||
**Problem:** Technitium on CT 107, NetBird managed DNS zone, and the public
|
||||
IONOS wildcard all answer `*.hubris.network` queries. The dns.md doc still
|
||||
references the old dnsmasq on LXC 124 (though the change log says it moved).
|
||||
NetBird's managed DNS bypasses Technitium entirely for some app names — there
|
||||
is no single source of truth for DNS.
|
||||
|
||||
**Risk:** Mismatched answers → services unreachable → "works on some clients
|
||||
but not others" debugging sessions. Already cost time when `auth.hubris.network`
|
||||
re-pointed to the VPS.
|
||||
|
||||
**Proposal:**
|
||||
- Phase out NetBird managed DNS zone for `hubris.network` — Technitium is the
|
||||
authoritative answerer for mesh & LAN clients
|
||||
- Set Technitium as the sole DNS for all LXCs (remove router DNS / Tailscale
|
||||
MagicDNS fallbacks)
|
||||
- Document the full authoritative chain: Technitium → upstream forwarders → public
|
||||
- Track Technitium config in git (dtoro/technitium-config or equivalent)
|
||||
|
||||
### 2. Backups disabled with no alternative (data loss risk)
|
||||
|
||||
**Problem:** The only backup was restic to an external USB that caused host
|
||||
crashes. It was disabled 2026-04-22 as an A/B test — host stability was
|
||||
confirmed by the SODOLA migration (no more Slate AX double-NAT hangs). The
|
||||
drive is still removed.
|
||||
|
||||
**Risk:** `/mnt/library` (~429 GB of irreplaceable data: photos, documents,
|
||||
git repos, gitea data) has no off-host copy. Single-disk failure = total loss.
|
||||
|
||||
**Proposal:**
|
||||
- Re-evaluate the USB drive stability with the new SODOLA switch topology
|
||||
(direct rear USB 3.0 port, no hub chain)
|
||||
- OR adopt a cloud-backed strategy: `rclone`-to-Hetzner Storage Box or
|
||||
Backblaze B2 for the irreplaceable subset (docs, photos, gitea data)
|
||||
- OR use hubris's own `zfs send` to a second host/disk if ZFS is feasible
|
||||
- Minimum viable: at minimum restore gitea backups + sops-encrypted secrets
|
||||
via an off-site cron (cheap B2 bucket)
|
||||
|
||||
### 3. Mesh migration still incomplete
|
||||
|
||||
**Problem:** Most LXCs still use Tailscale. This forces per-LXC DNS workarounds
|
||||
(/etc/hosts overrides, local dnsmasq) and runs two VPN stacks in parallel.
|
||||
Mesh migration doc (mesh.md) is comprehensive but execution stalled.
|
||||
|
||||
**Proposal:**
|
||||
- Batch-migrate all LXCs from Tailscale to Netbird in one maintenance window
|
||||
- Remove Tailscale from the PVE host
|
||||
- Ensure all LXCs resolve `*.hubris.network` via Technitium (no more /etc/hosts
|
||||
overrides)
|
||||
- Document Netbird client on each LXC (netbird version, setup key rotation)
|
||||
|
||||
### 4. LXC resource imbalance & disk pressure
|
||||
|
||||
**Problem:**
|
||||
| LXC | Cores | RAM | Rootfs | Disk usage |
|
||||
|-----|-------|-----|--------|------------|
|
||||
| mule-images (120) | 6 | 12 GiB | 60 GiB | photo AI, reasonable |
|
||||
| arriman (122) | 4 | 8 GiB | 24 GiB | media stack, OK |
|
||||
| nextcloud (114) | 4 | 6 GiB | 25 GiB | file sync, OK |
|
||||
| apps (105) | 2 | 4 GiB | 30 GiB | 6+ services, tight |
|
||||
| paperless (103) | 2 | 3 GiB | 8 GiB | 86.9% disk |
|
||||
| elementsynapse (118) | 1 | 2 GiB | 8 GiB | 86.8% disk |
|
||||
| gitea (104) | 1 | 1 GiB | 8 GiB | git server, adequate |
|
||||
| caddy (121) | 1 | 512 MiB | 6 GiB | fine for reverse proxy |
|
||||
|
||||
Paperless (103) and elementsynapse (118) are at critical disk levels. apps (105)
|
||||
is undersized for 6+ services.
|
||||
|
||||
**Proposal:**
|
||||
- Resize rootfs on paperless (8→16 GiB) and elementsynapse (8→16 GiB)
|
||||
- Bump apps (105) to 4 cores / 6 GiB RAM / 40 GiB rootfs
|
||||
- Enable claudio-monitor's disk check to alert before next crisis
|
||||
|
||||
### 5. VPS is a single point of failure
|
||||
|
||||
**Problem:** One IONOS VM runs netbird management (control plane), traefik
|
||||
(public ingress), authentik (identity), and coturn (TURN relay). If it goes
|
||||
down: no remote mesh, no public services, no auth.
|
||||
|
||||
**Proposal:**
|
||||
- Document a VPS recovery runbook (how to restore from a known-working backup)
|
||||
- Consider splitting authentik into a separate host or at minimum having a
|
||||
standby configuration
|
||||
- Not a high priority (the VPS has been stable) but worth documenting the
|
||||
blast radius and recovery path
|
||||
|
||||
### 6. No centralized logging
|
||||
|
||||
**Problem:** Each LXC has independent journald. Cross-service debugging
|
||||
involves hopping between `pct exec <id> -- journalctl -u <service>`. There is
|
||||
no aggregation or retention.
|
||||
|
||||
**Proposal:**
|
||||
- Deploy a lightweight log shipper (Loki + promtail, or vector.dev) on each LXC
|
||||
- Ship logs to a central Loki instance on apps (105) or a new small LXC
|
||||
- Grafana dashboard optional — even a simple `logcli` query saves time
|
||||
|
||||
### 7. Agent enrollment incomplete
|
||||
|
||||
**Problem:** Only hubris, apps, and republic-laptop are enrolled in the
|
||||
homelab-context system (age keys, sync timers, MCP access). mac-mini,
|
||||
ludo-mini, claudio-bot, and all other LXCs are not.
|
||||
|
||||
**Proposal:**
|
||||
- Batch-enroll remaining LXCs (jellyfin, paperless, gitea, nextcloud, etc.)
|
||||
- Enroll mac-mini (macOS — exercises the launchd timer path)
|
||||
- Enroll ludo-mini (needs SSH user config in inventory first)
|
||||
- Wire claudio-bot into inventory-aware queries
|
||||
|
||||
### 8. Configuration drift on untracked configs
|
||||
|
||||
**Problem:** Technitium config, dnsmasq (legacy), and several service-specific
|
||||
configs are not git-tracked.
|
||||
|
||||
**Proposal:**
|
||||
- Track Technitium zone backup + compose config in a git repo
|
||||
- Apply the same pattern as caddy-conf: dtoro/technitium-conf with auto-deploy
|
||||
|
||||
### 9. No capacity planning / resource monitoring
|
||||
|
||||
**Problem:** No trend data on CPU, RAM, or disk growth. The 86% disk alerts
|
||||
were discovered reactively. Rootfs resize is painful (requires Proxmox stop +
|
||||
resize + growfs inside).
|
||||
|
||||
**Proposal:**
|
||||
- Enable the missing claudio-monitor checks (disk growth trend, apt upgradable
|
||||
counts, docker image drift)
|
||||
- Set up a simple Prometheus + node_exporter on hubris or use the PVE API
|
||||
directly
|
||||
- At minimum, surface disk usage in the existing homelab-mcp management tools
|
||||
|
||||
### 10. No standard deploy / orchestration for bare-metal LXCs
|
||||
|
||||
**Problem:** Some services are bare-metal CLI apps (sophia, claudio-bot),
|
||||
some are Docker on apps (105), some are Portainer-managed. No consistent
|
||||
deploy pattern means every new service reinvents the deployment.
|
||||
|
||||
**Proposal:**
|
||||
- Don't over-engineer this — the current pragmatism works
|
||||
- Just document the decision tree:
|
||||
- Needs `/mnt/library` mount + heavy I/O → dedicated LXC
|
||||
- Small stateless web service → Docker on apps (105)
|
||||
- Media stack → dedicated LXC (arriman, jellyfin)
|
||||
- Everything else → judge by complexity
|
||||
|
||||
---
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 1 — Critical fixes (this week)
|
||||
1. Resize rootfs on paperless (103) 8→16 GiB, elementsynapse (118) 8→16 GiB
|
||||
2. Enable claudio-monitor disk check + disk-growth alerting
|
||||
3. Pick one backup strategy and implement minimum viable (e.g. nightly
|
||||
gitea dump + sops-encrypted secrets to B2 via rclone)
|
||||
4. Verify Technitium is the sole DNS for all LXCs (remove NetBird managed zone
|
||||
for hubris.network)
|
||||
|
||||
### Phase 2 — Mesh consolidation (next week)
|
||||
5. Batch-migrate remaining LXCs from Tailscale to Netbird
|
||||
6. Remove Tailscale from PVE host
|
||||
7. Remove all per-LXC /etc/hosts DNS overrides
|
||||
8. Update DNS documentation to reflect Technitium as single source
|
||||
|
||||
### Phase 3 — Agent enrollment & logging (next 2 weeks)
|
||||
9. Enroll all LXCs in homelab-context (age keys, sync timers)
|
||||
10. Enroll mac-mini (macOS launchd path — exercises untested code path)
|
||||
11. Enroll ludo-mini
|
||||
12. Deploy log shipper (Loki + promtail) on apps (105) + all LXCs
|
||||
|
||||
### Phase 4 — Resource & monitoring hardening (next month)
|
||||
13. Resize apps (105) rootfs, bump RAM
|
||||
14. Deploy Prometheus + node_exporter or equivalent for trend data
|
||||
15. Track Technitium config in git with auto-deploy
|
||||
16. Write VPS recovery runbook
|
||||
|
||||
### Phase 5 — Drive re-evaluation (optional, behind host-stability gate)
|
||||
17. Re-attach USB backup drive with the new SODOLA topology (direct port)
|
||||
18. If stable for 7 days, re-enable restic backup schedule (chunked)
|
||||
19. If not stable, finalize cloud backup as permanent strategy
|
||||
|
||||
---
|
||||
|
||||
## Files likely to change
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `/opt/homelab-context/inventory.yaml` | LXCs enrolled, resource updates |
|
||||
| `/opt/homelab-context/secrets/*.yaml` | New recipients for enrolled LXCs |
|
||||
| `/opt/homelab-context/infrastructure/dns.md` | Reflect Technitium as sole source |
|
||||
| `/opt/homelab-context/infrastructure/mesh.md` | Remove Tailscale references post-migration |
|
||||
| `/opt/homelab-context/infrastructure/backups.md` | New strategy |
|
||||
| `/opt/homelab-context/infrastructure/monitoring.md` | Enable missing checks |
|
||||
| `/opt/homelab-context/containers/103-paperless.md` | Rootfs resize |
|
||||
| `/opt/homelab-context/containers/118-elementsynapse.md` | Rootfs resize |
|
||||
| `/opt/homelab-context/containers/105-apps.md` | Resource bump, logging addition |
|
||||
| `/opt/homelab-context/containers/index.md` | Updated resource table |
|
||||
| `.sops.yaml` | New age pubkeys for enrolled LXCs |
|
||||
|
||||
## Verification
|
||||
|
||||
Each phase ends with a verification milestone:
|
||||
- Phase 1: `claudio-monitor` triggers on paperless disk → confirmed alert. Backup
|
||||
of gitea data lands in B2 (or equivalent). DNS query from any LXC returns
|
||||
Technitium answer.
|
||||
- Phase 2: `netbird status` shows all LXCs connected. Tailscale not running on
|
||||
PVE host. `curl auth.hubris.network` from any LXC resolves correctly without
|
||||
/etc/hosts.
|
||||
- Phase 3: Every LXC has `/opt/homelab-context/` + `/etc/age/key.txt`. MCP
|
||||
tools return valid host info for all enrolled LXC names. `journalctl` shows
|
||||
promtail shipping to Loki.
|
||||
- Phase 4: `claudio-monitor` shows disk growth trend. apps (105) can run all
|
||||
6+ services without OOM.
|
||||
|
||||
## Risks & tradeoffs
|
||||
|
||||
- **Netbird migration window:** All LXCs will briefly lose mesh connectivity
|
||||
during the Tailscale→Netbird cutover. Schedule in off-hours.
|
||||
- **Backup cost:** B2/e2 costs ~$5/month for ~500 GB. The USB drive was free
|
||||
but unstable — trade money for reliability.
|
||||
- **DNS consolidation:** Removing the NetBird managed DNS zone means any
|
||||
NetBird-specific names stop resolving for hubris.network — verify nothing
|
||||
depends on that path.
|
||||
- **Loki on apps (105):** Adds another container to an already-loaded host.
|
||||
May need to bump resources before deploying.
|
||||
- **Agent enrollment on every LXC:** Each enrollment creates an age keypair
|
||||
and commits a pubkey to inventory. Process is scriptable via `homelab client
|
||||
add` but still takes ~2 min per host for verification.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Is the USB backup drive still physically attached to hubris? If not, the
|
||||
simplest "re-enable" path requires physically re-attaching it.
|
||||
2. Authentik is now on the VPS — is LXC 124 (old Authentik) still running or
|
||||
was it fully decommissioned? The dns.md changelog says "shut down" but
|
||||
index.md lists it as "running".
|
||||
3. What's the actual disk layout on hubris? nvme0n1, ZFS pool, mount structure
|
||||
— needed to plan rootfs resizes safely.
|
||||
4. Does the user want to keep Tailscale on any host for a specific reason, or
|
||||
is full Netbird migration the clear goal?
|
||||
@@ -1,246 +0,0 @@
|
||||
# Plan: Narrow Technitium DHCP Pool to Avoid Static-IP Conflicts
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Eliminate the IP conflict risk created by the Technitium DHCP pool (`.100–.240`) overlapping with all static LXC/VM IPs (`.101–.239`).
|
||||
|
||||
**Architecture:** Shrink the DHCP pool range on Technitium so it only covers IPs that no static host uses. No LXC/VM IPs change. Single server-side change (Technitium API), plus documentation updates.
|
||||
|
||||
**Tech Stack:** Technitium DNS API (`/api/dhcp/scopes/set`), bash/curl, homelab-context repo for docs.
|
||||
|
||||
---
|
||||
|
||||
## Problem statement
|
||||
|
||||
The Technitium DHCP server on [CT 107](../../knowledge/wiki/containers/107-dns.md) serves `192.168.8.100–192.168.8.240`. **Every static homelab IP except hubris (`.77`) sits inside that range:**
|
||||
|
||||
| Host | IP | Inside pool? |
|
||||
|---|---|---|
|
||||
| hubris (Proxmox) | .77 | No — below `.100` |
|
||||
| haos (VM 108) | .101 | YES |
|
||||
| gitea (104) | .121 | YES |
|
||||
| paperless (103) | .130 | YES |
|
||||
| arriman (122) | .132 | YES |
|
||||
| mule-images (120) | .136 | YES |
|
||||
| sophia (119) | .157 | YES |
|
||||
| mac-mini | .174 | YES |
|
||||
| caddy (121) | .175 | YES |
|
||||
| authentik (124) | .180 | YES |
|
||||
| plato (126) | .190 | YES |
|
||||
| zimaos (VM 100) | .195 | YES |
|
||||
| nfs-export (102) | .200 | YES |
|
||||
| apps (105) | .205 | YES |
|
||||
| jellyfin (101) | .206 | YES |
|
||||
| nextcloud (114) | .224 | YES |
|
||||
| claudio-bot (123) | .230 | YES |
|
||||
| elementsynapse (118) | .239 | YES |
|
||||
|
||||
The docs claim "Static-IP LXCs (below `.100`) are unaffected" — this is **false**. Static IPs span `.101–.239`, the DHCP pool spans `.100–.240`. They overlap almost entirely.
|
||||
|
||||
If the DHCP server hands out `.121/.136/.224` (or any of the above) to a new dynamic client before the static LXC claims it on boot, the static service will fail to bind and the service goes dark.
|
||||
|
||||
---
|
||||
|
||||
## Proposed approach: Shrink the pool
|
||||
|
||||
**Move the DHCP pool start from `.100` to `.241`**, resulting in:
|
||||
- **New pool:** `192.168.8.241 – 192.168.8.254` (14 dynamic IPs)
|
||||
- **Reserved:** `.100–.240` stays for static hosts, `.2` for Technitium, `.1` for gateway
|
||||
- **Zero changes to any LXC, VM, Caddy, or Proxmox config.**
|
||||
|
||||
Why `.241–.254`:
|
||||
- Highest static IP is `.239` (elementsynapse) — `.241` gives a 1-IP gap
|
||||
- `.255` is the broadcast address (unusable)
|
||||
- 14 IPs is plenty for truly dynamic clients (new transient containers, test VMs)
|
||||
- If more are ever needed, the pool can easily be widened back down
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Verify current Technitium DHCP scope from the API
|
||||
|
||||
**Objective:** Confirm the active pool range matches what's documented.
|
||||
|
||||
**Step 1: Log in to Technitium API and get a token**
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -sk -X POST http://192.168.8.2:5380/api/user/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user":"admin","pass":"'$(cat /opt/technitium/admin_password.txt)'","includeInfo":false}' \
|
||||
| jq -r '.token')
|
||||
echo "Token: ${TOKEN:0:10}..."
|
||||
```
|
||||
|
||||
**Step 2: Fetch current DHCP scopes**
|
||||
|
||||
```bash
|
||||
curl -sk "http://192.168.8.2:5380/api/dhcp/scopes/list?token=$TOKEN" | jq .
|
||||
```
|
||||
|
||||
**Expected:** One scope named `homelab` with `startingAddress: "192.168.8.100"` and `endingAddress: "192.168.8.240"`.
|
||||
|
||||
**Verification:** If the scope is NOT `.100–.240`, note the actual range and adjust the plan.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update the DHCP scope to `.241–.254`
|
||||
|
||||
**Objective:** Shrink the pool so it no longer overlaps static IPs.
|
||||
|
||||
**Step 1: Update the scope via API**
|
||||
|
||||
```bash
|
||||
curl -sk -X POST "http://192.168.8.2:5380/api/dhcp/scopes/set?token=$TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "homelab",
|
||||
"startingAddress": "192.168.8.241",
|
||||
"endingAddress": "192.168.8.254",
|
||||
"subnetMask": "255.255.255.0",
|
||||
"gatewayAddress": "192.168.8.1",
|
||||
"dnsServerAddresses": ["192.168.8.2"],
|
||||
"leaseTime": 86400
|
||||
}'
|
||||
```
|
||||
|
||||
**Step 2: Verify the change took effect**
|
||||
|
||||
```bash
|
||||
curl -sk "http://192.168.8.2:5380/api/dhcp/scopes/list?token=$TOKEN" | jq '.response.scopes[0] | {startingAddress, endingAddress}'
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
```json
|
||||
{
|
||||
"startingAddress": "192.168.8.241",
|
||||
"endingAddress": "192.168.8.254"
|
||||
}
|
||||
```
|
||||
|
||||
**Pitfall:** If the API returns `{"status":"error"}`, the scope name or parameter format may differ. Inspect the response body. Technitium's API might use `rangeStart`/`rangeEnd` instead of `startingAddress`/`endingAddress`. Adjust if needed (check the full scope object from Task 1 step 2 for exact key names).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Check for active DHCP leases in the old pool that would be stranded
|
||||
|
||||
**Objective:** Ensure no DHCP client is currently holding an IP in `.100–.240` that it will lose when its lease expires.
|
||||
|
||||
**Step 1: List active DHCP leases**
|
||||
|
||||
```bash
|
||||
curl -sk "http://192.168.8.2:5380/api/dhcp/leases/list?token=$TOKEN" | jq '.response.leases[] | {ip: .ipAddress, client: .clientHostname, mac: .hardwareAddress, expires: .leaseExpires}'
|
||||
```
|
||||
|
||||
**Step 2: Interpret results**
|
||||
|
||||
- If the only leases are from static LXCs that configured themselves before the DHCP move (e.g., old leases from before the 2026-06-02 static-IP migration), these leases are stale and harmless.
|
||||
- If a *dynamic* client (e.g., a test laptop, transient VM) holds `.195` or similar, note it — it will lose its IP on next renew and should be moved to a static assignment or into the `.241+` pool.
|
||||
- **ZimaOS (VM 100) at `.195` is a DHCP lease, not static** — this is the one host that needs attention. Either:
|
||||
- Set a static IP inside ZimaOS (preferred), or
|
||||
- Add a DHCP reservation for MAC in Technitium to pin `.195`
|
||||
|
||||
**Verification:** No "surprise" dynamic clients that would break on lease expiry.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Fix ZimaOS IP stability (if needed)
|
||||
|
||||
**Objective:** Ensure ZimaOS at `.195` won't float or break when the pool shrinks.
|
||||
|
||||
**If ZimaOS already has a static IP configured inside the VM:** Nothing to do.
|
||||
|
||||
**If ZimaOS is DHCP-only (likely — doc says "DHCP lease, not a reservation"):**
|
||||
|
||||
Option A (preferred): Set a static IP inside ZimaOS via its web UI at `http://192.168.8.195` → Settings → Network → Static IP → `192.168.8.195/24`, gateway `192.168.8.1`, DNS `192.168.8.2`.
|
||||
|
||||
Option B: Add a DHCP reservation in Technitium for ZimaOS's MAC address:
|
||||
```bash
|
||||
ZIMAMAC=$(ssh root@hubris "qm config 100 | grep net0 | grep -oE '([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}'")
|
||||
curl -sk -X POST "http://192.168.8.2:5380/api/dhcp/reservations/add?token=$TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"hardwareAddress\":\"$ZIMAMAC\",\"ipAddress\":\"192.168.8.195\"}"
|
||||
```
|
||||
|
||||
**Pitfall:** The `/api/dhcp/reservations/add` endpoint signature is unverified — confirm the exact endpoint name from Technitium's API docs or the web UI before running it. The web console at `http://192.168.8.2:5380` → DHCP → Reservations can be used as a manual fallback.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update documentation in homelab-context
|
||||
|
||||
**Objective:** Fix the now-wrong claims about static IPs being "below .100".
|
||||
|
||||
**Files to edit:**
|
||||
|
||||
1. **`infrastructure/network.md`** — Line 53
|
||||
- Old: `Most homelab LXCs use static IPs below \`.100\`. DHCP only covers new/transient containers.`
|
||||
- New: `Static IPs span \`.101–.239\` (all LXCs + VMs + workstations). DHCP pool narrowed to \`.241–.254\` to avoid overlap.`
|
||||
|
||||
2. **`containers/107-dns.md`** — Lines 37, 42, 55
|
||||
- Line 37: Update pool range: `192.168.8.241 – 192.168.8.254`
|
||||
- Line 42: `Static-IP LXCs (below \`.100\`)` → `Static-IP LXCs (\`.101–.239\`) are excluded from the pool.`
|
||||
- Line 55: Add changelog entry for the pool shrink
|
||||
|
||||
3. **`containers/107-dns.md`** — Add changelog entry:
|
||||
```markdown
|
||||
### 2026-06-03 — DHCP pool narrowed to `.241–.254` to exclude static IPs
|
||||
Previous pool `.100–.240` overlapped with all static LXCs/VMs (\`.101–.239\`), creating IP conflict risk. Shrunk pool to `.241–.254`. No services re-IP'd. See [plan](../plans/2026-06-03-dhcp-pool-exclude-static-ips.md).
|
||||
```
|
||||
|
||||
4. **`infrastructure/network.md`** — Line 51: Update pool range in the DHCP table row.
|
||||
|
||||
5. **`plans/2026-06-01-slate-ax-to-sodola-migration.md`** — Line 60: Optionally update the pool range in the config table (or add a post-migration note). This is the historical migration plan, so a footnote rather than an edit may be better.
|
||||
|
||||
**Commit:**
|
||||
```bash
|
||||
cd /opt/homelab-context
|
||||
git add infrastructure/network.md containers/107-dns.md plans/
|
||||
git commit -m "docs: DHCP pool narrowed to .241-.254 to exclude static IPs"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Verify no regressions
|
||||
|
||||
**Objective:** Smoke-test that DNS and key services still work after the scope change.
|
||||
|
||||
```bash
|
||||
# 1. DNS resolution via Technitium
|
||||
dig @192.168.8.2 +short git.hubris.network
|
||||
# Expected: 192.168.8.175
|
||||
|
||||
# 2. Caddy reverse-proxy chain
|
||||
curl -sI https://git.hubris.network | head -1
|
||||
# Expected: HTTP/2 200
|
||||
|
||||
# 3. All app names resolve
|
||||
for name in git cloud media paperless photos matrix auth plato artifacto; do
|
||||
result=$(dig @192.168.8.2 +short ${name}.hubris.network)
|
||||
printf "%-20s → %s\n" "${name}.hubris.network" "$result"
|
||||
done
|
||||
|
||||
# 4. Technitium DHCP scope is correct
|
||||
curl -sk "http://192.168.8.2:5380/api/dhcp/scopes/list?token=$TOKEN" | jq '.response.scopes[0] | {startingAddress, endingAddress}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| API call fails (wrong field names) | Medium | Low | Inspect live scope object first (Task 1); adjust payload |
|
||||
| ZimaOS loses IP on next boot | Low | Medium | Task 4 makes ZimaOS static or reserved |
|
||||
| Active DHCP client in `.100–.240` gets stranded | Low | Low | Task 3 surfaces this; client just requests a new IP from `.241+` |
|
||||
| Technitium admin password file missing | Low | Medium | `/opt/technitium/admin_password.txt` was created during setup; verify existence |
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Is zimaos (VM 100) currently DHCP or static? The doc says DHCP lease, but it's listed as `lan_ip: 192.168.8.195` in inventory. If it's actually DHCP, it's the one host that needs a static assignment before the pool shrinks.
|
||||
2. Are there any transient DHCP clients (test laptops, phones) on the homelab subnet that hold `.100–.240` addresses? Check leases before cutting over.
|
||||
3. Should we widen the pool slightly (e.g., `.230–.254`) for more headroom? Currently 14 IPs. If 3+ transient devices are expected, `.230–.254` = 25 IPs — still safe since the highest static is `.239` and `.230–.239` could be excluded.
|
||||
|
||||
## Execution preference
|
||||
|
||||
All changes are on the Technitium API + homelab-context repo. No LXC/VM restarts needed. The pool shrink takes effect immediately for NEW DHCP requests; existing leases in the old range continue until expiry (24h max).
|
||||
@@ -1,129 +0,0 @@
|
||||
# Plan: Prevent DHCP IP drift from breaking Caddy backends
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Slug:** prevent-dhcp-ip-drift
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Eliminate the root cause of services becoming unreachable when DHCP lease renewals change backend IPs that Caddy's `reverse_proxy` directives hardcode.
|
||||
|
||||
**Triggering incident:** Paperless (LXC 103) and HAOS (VM 108) had DHCP-assigned IPs change from `.130→.243` and `.101→.241` respectively. Caddyfile still pointed at the old IPs → services unreachable from iPhone on Netbird.
|
||||
|
||||
## Current context
|
||||
|
||||
### DHCP vs static IP inventory
|
||||
|
||||
| Machine | Type | PVE ID | Current IP | Allocation method | Status |
|
||||
|---------|------|--------|-----------|-------------------|--------|
|
||||
| **jellyfin** | LXC | 101 | 192.168.8.206 | Static (`ip=.../24`) | ✅ |
|
||||
| **paperless** | LXC | 103 | 192.168.8.243 | **DHCP** (`ip=dhcp`) | ❌ broken, hotfixed to .243 |
|
||||
| **gitea** | LXC | 104 | 192.168.8.121 | Static | ✅ |
|
||||
| **apps** | LXC | 105 | 192.168.8.205 | Static | ✅ |
|
||||
| **nextcloud** | LXC | 114 | 192.168.8.224 | Static | ✅ |
|
||||
| **elementsynapse** | LXC | 118 | 192.168.8.239 | Static | ✅ |
|
||||
| **mule-images** | LXC | 120 | 192.168.8.136 | Static | ✅ |
|
||||
| **caddy** | LXC | 121 | 192.168.8.175 | Static | ✅ |
|
||||
| **arriman** | LXC | 122 | 192.168.8.132 | Static | ✅ |
|
||||
| **sophia** | LXC | 119 | 192.168.8.157 | Static? | ? (not in 2026-06-02 list) |
|
||||
| **nfs-export** | LXC | 102 | 192.168.8.200 | Static? | ? |
|
||||
| **plato** | LXC | 126 | 192.168.8.190 | Static? | ? |
|
||||
| **HAOS** | VM | 108 | 192.168.8.241 | **DHCP** (VM — OS-managed) | ❌ broken, hotfixed to .241 |
|
||||
| **zimaos** | VM | 100 | 192.168.8.195 | DHCP (known stale lease, see note) | ⚠️ open issue |
|
||||
| **authentik** | — | — | — | migrated to VPS (external) | N/A |
|
||||
|
||||
### Infrastructure facts
|
||||
|
||||
- **DHCP server:** Technitium on CT 107 (192.168.8.2), pool `.241–.254`
|
||||
- **Static IP range:** `.101–.239`
|
||||
- **DNS:** Split-horizon on Technitium — `*.hubris.network → 192.168.8.175` (Caddy itself)
|
||||
- **Caddyfile:** Has 29 `reverse_proxy` directives, all using **hardcoded IP:port**
|
||||
- **Caddy reload:** Auto-deployed via webhook on git push to `dtoro/caddy-conf`
|
||||
- **Documentation:** `inventory.yaml` updated, `hosts/*.yaml` stale-before-regenerate
|
||||
|
||||
### Why it happened
|
||||
|
||||
1. Paperless LXC 103 was **missed** during the 2026-06-02 static-IP migration (Proxmox config still shows `ip=dhcp`)
|
||||
2. HAOS VM 108 uses DHCP internally (HAOS manages its own network — can't just `pct set`)
|
||||
3. Both got new IPs from the Technitium `.241–.254` pool after a lease renewal
|
||||
4. Caddyfile still pointed at the old static-range IPs → connection refused
|
||||
|
||||
---
|
||||
|
||||
## Proposed approach
|
||||
|
||||
Three-layer solution:
|
||||
|
||||
### Layer 1: Fix the immediate offenders (static assignment)
|
||||
|
||||
**Paperless LXC 103:**
|
||||
- `pct set 103 --net0 name=eth0,bridge=vmbr0,gw=192.168.8.1,hwaddr=BC:24:11:0A:8D:C2,ip=192.168.8.130/24,ip6=auto,type=veth`
|
||||
- Inside the LXC, update `/etc/network/interfaces` to match
|
||||
- Reboot/restart networking
|
||||
|
||||
**HAOS VM 108:**
|
||||
- Set a **DHCP reservation** in Technitium for the VM's MAC address, pinning it to `192.168.8.101`
|
||||
- This avoids needing to reconfigure HAOS internally (which is tedious)
|
||||
- Alternatively: use `ha network update` from the HAOS console to set a static IP
|
||||
|
||||
### Layer 2: Audit and fix all remaining DHCP hosts
|
||||
|
||||
Check every LXC/VM hosted on hubris:
|
||||
```bash
|
||||
for ct in $(pct list | awk 'NR>1{print $1}'); do
|
||||
echo "=== CT $ct ==="
|
||||
pct config $ct | grep "^net0"
|
||||
done
|
||||
```
|
||||
Any with `ip=dhcp` that Caddy reverse-proxies to → convert to static.
|
||||
|
||||
**Known candidates to check:**
|
||||
- CT 102 (nfs-export) — `.200` but not in Caddy. May not need static.
|
||||
- CT 119 (sophia) — `.157` — is this static or DHCP? Not sure.
|
||||
- CT 126 (plato) — `.190` — same question.
|
||||
- VM 100 (zimaos) — `.195` but known to have a stale lease (see 2026-06-03 changelog)
|
||||
|
||||
### Layer 3: Add validation
|
||||
|
||||
Create a script that runs periodically (cron or homelab cronjob):
|
||||
|
||||
**`/opt/homelab-context/scripts/check-caddy-backends.sh`:**
|
||||
1. Parse `/etc/caddy/Caddyfile` on CT 121 to extract all `reverse_proxy IP:port` targets
|
||||
2. For each `IP:port`, attempt a TCP connect (timeout 3s)
|
||||
3. Report any that fail
|
||||
|
||||
Could also run as a homelab cron job that notifies dtoro on Matrix if a backend is unreachable.
|
||||
|
||||
This catches any future drift proactively (before a user reports it).
|
||||
|
||||
### Files likely to change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `inventory.yaml` | May update paperless/HAOS IPs if we choose different static IPs |
|
||||
| LXC 103 Proxmux config (via `pct set`) | Set static IP |
|
||||
| Technitium DHCP reservations | Add HAOS reservation |
|
||||
| `/etc/caddy/Caddyfile` on CT 121 | Already fixed — only changes again if we re-assign paperless IP to `.130` |
|
||||
| `scripts/check-caddy-backends.sh` | New validation script (new file in homelab-context) |
|
||||
|
||||
### Risks / Tradeoffs
|
||||
|
||||
- **Pinning paperless to `.130`** — if the LXC was reinstalled since then, `.130` may already be in use. Verify first with `arp-scan` or `nmap`.
|
||||
- **HAOS static IP via Technitium reservation** vs **inside HAOS**: Technitium reservation is simpler (no HA config changes), but if HAOS's DHCP lease expires and the Technitium server is down, the reservation won't help. A static IP inside HAOS is more robust but requires poking the HA console.
|
||||
- **Validation script false positives** — a service might be legitimately down for maintenance. The script should be a warning, not an alert.
|
||||
- **Caddy reload** — each Caddyfile edit triggers an auto-reload via webhook. If the backend is down during reload, Caddy itself stays up (it's just a reverse_proxy target).
|
||||
|
||||
### Verification
|
||||
|
||||
1. After setting paperless static: `ssh root@192.168.8.175 "curl -s -o /dev/null -w '%{http_code}' http://192.168.8.130:8000"` → 302
|
||||
2. After Technitium HAOS reservation: `curl -s -o /dev/null -w '%{http_code}' http://192.168.8.101:8123` → 200
|
||||
3. Run validation script → all targets reachable
|
||||
4. Confirm from iPhone: both `paperless.hubris.network` and `home.hubris.network` load
|
||||
|
||||
### Open questions
|
||||
|
||||
1. Should paperless go back to `.130` (its original), or stay at `.243` (current)? Going back to `.130` means updating the Caddyfile again, but keeps the static range allocation consistent.
|
||||
2. HAOS: Technitium reservation or HAOS-internal static config? Reservation is easier; HAOS-internal is more robust.
|
||||
3. Should the Caddyfile validation script run as a homelab cron job, or as a cron on the caddy LXC itself?
|
||||
4. ZimaOS (VM 100) — should we also pin its IP while we're at it?
|
||||
@@ -1,130 +0,0 @@
|
||||
# Plan: Fix Frequent Authentik Login Prompts
|
||||
|
||||
## Goal
|
||||
|
||||
Stop requiring repeated login to Authentik (several times per day) by fixing session and cookie expiry settings so the user stays logged in for longer periods (e.g., 7–30 days, or until explicit logout).
|
||||
|
||||
## Current Context
|
||||
|
||||
Authentik runs on the VPS (`82.165.190.79`) in Docker Compose. Traffic flows:
|
||||
|
||||
```
|
||||
Browser → Caddy (LXC 121) → VPS Traefik → Authentik
|
||||
```
|
||||
|
||||
Caddy's `forward_auth` uses the `(authentik)` snippet which proxies to `auth.hubris.network/outpost.goauthentik.io/auth/caddy`. The Authentik server version is **2026.5.2**.
|
||||
|
||||
## Root Cause Found
|
||||
|
||||
### Primary: `SESSION_EXPIRE_AT_BROWSER_CLOSE = True`
|
||||
|
||||
The Authentik Django session (`authentik_session` cookie) is configured to **expire on browser close**. Every time the user closes and reopens their browser, the session cookie is cleared. The next visit to a service that requires OAuth2 authorization (Gitea, Jellyfin, etc.) will redirect to the Authentik login page.
|
||||
|
||||
### Secondary: `SESSION_COOKIE_AGE = 86400` (24 hours)
|
||||
|
||||
Even with the browser left open continuously, the session expires after 24 hours. Combined with `SESSION_SAVE_EVERY_REQUEST = False`, activity does NOT extend the session.
|
||||
|
||||
### Session configuration (from Docker Python environment):
|
||||
|
||||
| Setting | Current Value | Default in Django |
|
||||
|---------|---------------|-------------------|
|
||||
| `SESSION_EXPIRE_AT_BROWSER_CLOSE` | `True` | `False` |
|
||||
| `SESSION_COOKIE_AGE` | `86400` (24h) | `1209600` (14d) |
|
||||
| `SESSION_SAVE_EVERY_REQUEST` | `False` | `False` |
|
||||
| `SESSION_COOKIE_SAMESITE` | `Lax` | `Lax` |
|
||||
|
||||
### What ISN'T the problem:
|
||||
|
||||
- **Proxy cookie validity** — `hubris-forward-auth` has `access_token_validity = hours=24`, which is reasonable for the forward-auth token.
|
||||
- **Server-side session duration** — The `user_login` stage has `session_duration = seconds=0` (indefinite).
|
||||
- **Refresh tokens** — All OAuth2 providers have `refresh_token_validity = days=30`, which is fine.
|
||||
- **Caddy configuration** — The forward-auth chain is correctly set up.
|
||||
- **Outpost health** — All containers healthy, up for 6 days.
|
||||
|
||||
## Proposed Approach
|
||||
|
||||
Change two Django session settings via Authentik environment variables:
|
||||
|
||||
1. **`AUTHENTIK_SESSION_COOKIE_AGE` = 604800** (7 days) — extends session cookie lifetime from 24h to 7 days
|
||||
2. **`AUTHENTIK_SESSION_EXPIRE_AT_BROWSER_CLOSE` = false** — prevents session cookie from being cleared on browser close
|
||||
|
||||
This keeps users logged in for up to 7 days with normal browser use (close/reopen, daily usage). The session still expires after 7 days of inactivity (`SESSION_SAVE_EVERY_REQUEST` stays False).
|
||||
|
||||
## Step-by-step Plan
|
||||
|
||||
### Step 1: Add environment variables to Docker compose
|
||||
|
||||
Edit `/opt/docker-compose.yml` on the VPS to add these env vars to the `authentik-server` service:
|
||||
|
||||
```yaml
|
||||
authentik-server:
|
||||
environment:
|
||||
# ... existing vars ...
|
||||
AUTHENTIK_SESSION_COOKIE_AGE: "604800" # 7 days (was 86400 / 24h)
|
||||
AUTHENTIK_SESSION_EXPIRE_AT_BROWSER_CLOSE: "false" # was true
|
||||
```
|
||||
|
||||
Note: the Authentik config system uses `__` (double underscore) for nesting. The env vars map to the Django settings via the config YAML path. The correct Authentik env var for `SESSION_COOKIE_AGE` would be `AUTHENTIK_SESSION__COOKIE_AGE` if it goes through the config system, or just `SESSION_COOKIE_AGE` if it's passed directly. Need to verify the exact variable name Authentik expects.
|
||||
|
||||
### Step 2: Verify variable naming
|
||||
|
||||
Check the Authentik config YAML (`/authentik/lib/default.yml` inside the container) to confirm the exact env var name mapping. Authentik uses a custom config layer that maps env vars to settings.
|
||||
|
||||
**Alternative if env vars don't work:** Some Authentik settings need to be set via the admin UI (under System Settings or Tenant settings). The Django session settings might need to be configured differently in this version.
|
||||
|
||||
### Step 3: Restart Authentik server
|
||||
|
||||
```bash
|
||||
ssh root@82.165.190.79
|
||||
docker compose -f /opt/docker-compose.yml restart authentik-server
|
||||
```
|
||||
|
||||
### Step 4: Verify the fix
|
||||
|
||||
```bash
|
||||
# Check session settings took effect
|
||||
ssh root@82.165.190.79 'docker exec -i authentik-server python3 << "PYEOF"
|
||||
import os
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
|
||||
import django
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
print("SESSION_EXPIRE_AT_BROWSER_CLOSE:", settings.SESSION_EXPIRE_AT_BROWSER_CLOSE)
|
||||
print("SESSION_COOKIE_AGE:", settings.SESSION_COOKIE_AGE)
|
||||
PYEOF'
|
||||
```
|
||||
|
||||
### Step 5: Functional test
|
||||
|
||||
1. Login to Authentik at `auth.hubris.network`
|
||||
2. Close the browser completely
|
||||
3. Re-open browser, navigate to a forward-auth-gated service (e.g., paperless.hubris.network)
|
||||
4. Verify you're NOT redirected to login
|
||||
5. Verify OAuth2 services (Gitea) also maintain the session
|
||||
|
||||
## Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `/opt/docker-compose.yml` | Add `AUTHENTIK_SESSION_COOKIE_AGE` and `AUTHENTIK_SESSION_EXPIRE_AT_BROWSER_CLOSE` env vars |
|
||||
|
||||
## Tests / Validation
|
||||
|
||||
1. **Config verification** — Run Python snippet inside container to confirm Django settings changed
|
||||
2. **Browser test** — Close/reopen browser, verify session persists (Step 5 above)
|
||||
3. **24-hour test** — Check session is still alive after 24h of normal use
|
||||
|
||||
## Risks, Tradeoffs, and Open Questions
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Env var names don't match Authentik's config schema | First verify in the container's `default.yml` config file |
|
||||
| 7-day persistent cookie is a security concern (stolen cookie = 7 days of access) | This is the same risk as any "Remember Me" feature on any web app. The tradeoff is convenience vs. security. |
|
||||
| The proxy cookie (`authentik_proxy_*`) may still have its own 24h limit | That's managed separately via the OAuth2 provider's `access_token_validity` setting. If we also want to extend that, we can update `hubris-forward-auth` provider's `access_token_validity` from `hours=24` to `days=7`. |
|
||||
| `SESSION_COOKIE_SECURE = False` | Should be `True` since Authentik is served behind HTTPS. However, the forward-auth subrequest from Caddy to the outpost is HTTP internally (`http://127.0.0.1:8099`), so `False` may be intentional for the outpost check. |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What environment variable name does Authentik use for Django session settings?** Need to check `default.yml`. The config layer may use `AUTHENTIK_SESSION__COOKIE_AGE` (double underscore) or the raw Django setting name.
|
||||
2. **Should we also extend the proxy token validity?** The `hubris-forward-auth` provider has `access_token_validity = hours=24`. If we want users to not need re-login for more than 24h, we should also bump this to match the session cookie age.
|
||||
3. **Which specific service triggers the most login prompts?** The forward-auth (Caddy-gated) services use proxy cookies. OAuth2 services (Gitea, Jellyfin) use the Django session. Understanding which one the user is hitting most could narrow the fix scope.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Plan: Fix Caddyfile truncation + prevent recurring outages
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Slug:** caddyfile-truncation-permanent-fix
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Restore all `*.hubris.network` services that went offline when the Caddyfile on LXC 121 was truncated to only 3 photo-related site blocks, and implement automated safeguards to prevent this class of outage from recurring.
|
||||
|
||||
## Root cause
|
||||
|
||||
The Caddyfile at `/etc/caddy/Caddyfile` on LXC 121 was manually edited locally (not via the `dtoro/caddy-conf` git repo), overwriting ~260 lines (30+ site blocks + forward-auth infrastructure) with only 43 lines covering `photos.hubris.network`, `prism.hubris.network`, and a manually-added `photos2.hubris.network`.
|
||||
|
||||
**Evidence:**
|
||||
- `git diff HEAD -- Caddyfile` shows `+3 / -159` lines diff — all other blocks deleted
|
||||
- Git reflog shows HEAD at `32575ce` (`fix: sab... port 8081→8082`), but working tree diverges
|
||||
- Deploy webhook log: Jun 06 12:39 — `deploy failed: git pull` (dirty tree blocks merge)
|
||||
- Backup file `Caddyfile.bak.1780263919` (225 lines) confirms the full original was intact before truncation
|
||||
- `origin/master` at `1b977aa` is the authoritative source — 260 lines, all blocks present
|
||||
|
||||
**Why "third time this week":**
|
||||
| Incident | Date | Cause |
|
||||
|---|---|---|
|
||||
| 1 | Jun 02 | DHCP IP drift — paperless (130→243), HAOS (101→241) |
|
||||
| 2 | Jun 05 | More DHCP drift — apps (205), mule-images (136 overridden by dhclient) |
|
||||
| 3 | Jun 06 | **Caddyfile truncated** — unrelated to IPs, much worse |
|
||||
|
||||
The Caddyfile truncation is the most severe: it took down **all LAN services** except `photos.hubris.network` and `auth.hubris.network` (VPS-hosted).
|
||||
|
||||
## Immediate fix
|
||||
|
||||
### Step 1: Restore Caddyfile from origin/master and reload
|
||||
|
||||
On LXC 121:
|
||||
|
||||
```bash
|
||||
cd /etc/caddy
|
||||
# Stash any local changes
|
||||
git stash
|
||||
# Reset to origin/master
|
||||
git checkout --force origin/master -- Caddyfile
|
||||
# Caddyfile now has all 30+ sites
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
```
|
||||
|
||||
This restores all service blocks including: media, git, paperless, books, home, cloud, matrix, proxmox, docker, jellyseerr, qbit, sab, blog, auth, artifacto, plato, zimaos, mcp, secrets, sso + authentik forward-auth infrastructure.
|
||||
|
||||
### Step 2: Add `photos2.hubris.network` via git (if still needed)
|
||||
|
||||
The `photos2.hubris.network` block was manually added locally and is NOT in origin/master. If the user wants to keep it, submit a PR/commit to the `dtoro/caddy-conf` repo.
|
||||
|
||||
### Step 3: Verify
|
||||
|
||||
- From any LAN/mesh client: `curl -sk https://media.hubris.network/` → 200
|
||||
- Run `bash /opt/homelab-context/scripts/check-caddy-backends.sh` from hubris → all targets reachable
|
||||
- Flush mac-mini DNS: `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder`
|
||||
|
||||
## Permanent safeguards
|
||||
|
||||
### Layer 1: Caddyfile integrity check (deploy hook)
|
||||
|
||||
Add a site-count validation to the deploy script (`/etc/caddy/scripts/deploy.sh`):
|
||||
|
||||
```bash
|
||||
# Count site blocks (lines matching *.hubris.network {)
|
||||
SITE_COUNT=$(grep -c '^[a-z].*hubris.network {' Caddyfile)
|
||||
if [ "$SITE_COUNT" -lt 20 ]; then
|
||||
echo "[deploy] ERROR: Only $SITE_COUNT sites found (expected 20+). Refusing to reload."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
This catches any future truncation before `caddy reload` runs.
|
||||
|
||||
### Layer 2: Caddyfile backup on deploy
|
||||
|
||||
Add to deploy script before git pull:
|
||||
|
||||
```bash
|
||||
cp Caddyfile "Caddyfile.bak.$(date +%s)"
|
||||
```
|
||||
|
||||
Keep last 3 backups, auto-rotate.
|
||||
|
||||
### Layer 3: Dirty-tree handling in deploy webhook
|
||||
|
||||
The deploy webhook currently hard-fails when the working tree is dirty. Change the receiver script to handle this gracefully:
|
||||
|
||||
```bash
|
||||
cd /etc/caddy
|
||||
# If dirty, stash local changes
|
||||
if ! git diff --quiet; then
|
||||
echo "[deploy] Working tree dirty — stashing"
|
||||
git stash push -m "auto-stash by deploy webhook $(date)"
|
||||
fi
|
||||
git pull --ff-only
|
||||
```
|
||||
|
||||
This prevents the webhook from blocking on future local edits.
|
||||
|
||||
### Layer 4: Scheduled Caddyfile health check
|
||||
|
||||
Add a homelab cron job that runs `check-caddy-backends.sh` every 10 minutes and notifies if any Caddy backend is unreachable.
|
||||
|
||||
```yaml
|
||||
# In homelab context: cronjob
|
||||
schedule: "*/10 * * * *"
|
||||
script: /opt/homelab-context/scripts/check-caddy-backends.sh
|
||||
```
|
||||
|
||||
### Layer 5: DNS sync cron (fix already-deployed sync)
|
||||
|
||||
The `dns-sync.py` on LXC 107 at `/opt/dns-sync/sync.py` is installed but has **no crontab** — the sync never runs automatically. The NetBird managed DNS zone has drifted from Technitium. Add a systemd timer or crontab:
|
||||
|
||||
```bash
|
||||
echo "*/10 * * * * root python3 /opt/dns-sync/sync.py >> /var/log/dns-sync.log 2>&1" > /etc/cron.d/dns-sync
|
||||
```
|
||||
|
||||
## Files likely to change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `/etc/caddy/Caddyfile` on LXC 121 | Restore from origin/master |
|
||||
| `/etc/caddy/scripts/deploy.sh` on LXC 121 | Add site-count validation + backup + dirty-tree handling |
|
||||
| `caddy-conf` git repo | PR with deploy.sh improvements + photos2 (if wanted) |
|
||||
| `cronjob` in Hermes | Schedule `check-caddy-backends.sh` |
|
||||
| `/etc/cron.d/dns-sync` on LXC 107 | New — add dns-sync cron |
|
||||
|
||||
## Verification
|
||||
|
||||
1. All `*.hubris.network` URLs load from mac-mini: `media`, `git`, `paperless`, `cloud`, `home`, `proxmox`, etc.
|
||||
2. `check-caddy-backends.sh` exits 0 on hubris
|
||||
3. `systemctl status caddy` shows active on LXC 121
|
||||
4. `dns-sync` runs and writes to `/var/log/dns-sync.log`
|
||||
|
||||
## Risks / Tradeoffs
|
||||
|
||||
- **Restoring from origin/master overwrites photos2.hubris.network** — recreate it via proper git commit
|
||||
- **Caddy staging ACME certs for prism/photos2**: The `tls dns ionos` directive uses staging env (`acme-staging-v02.api.letsencrypt.org`), which fails DNS propagation check (VPS port 53 unreachable from LXC). Once restored, these two subdomains will have the same issue. Move them to production IONOS DNS-01 by removing the staging CA directive or setting the correct `acme_issuer` in Caddyfile.
|
||||
- **Dirty-tree stash could lose edits** — mitigated by `git stash push --message` + backup file creation before stash
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Keep `photos2.hubris.network`? If yes, add via proper git push.
|
||||
2. `prism.hubris.network` and `photos2` certs fail on staging ACME — set production `acme_issuer` in Caddyfile?
|
||||
3. Should `check-caddy-backends.sh` run as a homelab cron job or as a regular cron on LXC 121?
|
||||
@@ -1,358 +0,0 @@
|
||||
# Assessment: Which nodes can move to `strong`
|
||||
|
||||
## Executive summary
|
||||
|
||||
hubris is **memory-starved**: 28 GiB RAM, 71.9 GiB allocated across 18 LXC + 2 VM
|
||||
(2.5× overcommit), 10 GiB swap in active use. strong sits **completely empty** —
|
||||
28 GiB RAM, 25 GiB free, 0 guests, 2.7 TiB unused storage. The single most
|
||||
effective decongestion move is to shift guests off hubris onto strong.
|
||||
|
||||
This document assesses every guest for move-readiness, grouped by constraints
|
||||
(library dependency, GPU, core-infra status), and proposes a phased migration
|
||||
that does **not** require the physical library-SSD move (the blocker of the
|
||||
original plan) — library access from strong is provided via NFS from hubris.
|
||||
|
||||
---
|
||||
|
||||
## Current resource state (live, 2026-07-05)
|
||||
|
||||
### hubris — overloaded
|
||||
|
||||
| Resource | Capacity | Allocated (all guests) | Actual use | Status |
|
||||
|----------|----------|----------------------|------------|--------|
|
||||
| RAM | 28 GiB | 70.2 GiB (2.5× overcommit) | 18 GiB used + 10 GiB swap | ⚠️ heavy swap pressure |
|
||||
| CPU | 12 vCPU (6c/12t) | 43 vCPU (3.6× overcommit) | ~43% scaling MHz | OK (shares) |
|
||||
| local-lvm | 856 GiB | 582 GiB allocated (29% thin) | — | OK |
|
||||
| library (lvmthin) | 3.7 TiB | — | 1.2 TiB used (34%) | OK, 2.3 TiB free |
|
||||
|
||||
### strong — empty, ready
|
||||
|
||||
| Resource | Capacity | Used | Status |
|
||||
|----------|----------|------|--------|
|
||||
| RAM | 28 GiB | 2.3 GiB (host only) | 25 GiB free |
|
||||
| CPU | 16 vCPU (8c/16t) | idle (0.08 load) | 100% free |
|
||||
| local-lvm | 856 GiB | 0 | empty |
|
||||
| ludo-lvm | 1.8 TiB | 0 | empty |
|
||||
| Guests | — | 0 LXC, 0 VM | nothing running |
|
||||
|
||||
### Network topology constraint
|
||||
|
||||
```
|
||||
Fritz!Box (192.168.178.1)
|
||||
└── SODOLA 2.5G switch
|
||||
├── hubris eno1 → vmbr1 (192.168.178.10) → vmbr0 (192.168.8.0/24)
|
||||
│ └── all 20 guests on 192.168.8.x
|
||||
└── strong vmbr0 (192.168.178.181)
|
||||
└── no internal bridge yet, guests would be on 192.168.178.x
|
||||
```
|
||||
|
||||
strong reaches `192.168.8.0/24` via the Fritz static route through hubris.
|
||||
Guests on strong get `192.168.178.x` IPs unless we add an internal bridge
|
||||
on strong (Phase 0 prerequisite — see below).
|
||||
|
||||
---
|
||||
|
||||
## Per-guest assessment
|
||||
|
||||
### Tier 1 — Move immediately (no library dependency, no core-infra)
|
||||
|
||||
These guests mount **no** `/mnt/library` and are not part of the core
|
||||
infrastructure spine (caddy/dns/auth/mcp). They are the easiest wins.
|
||||
|
||||
| ID | Name | Cores | RAM | Library? | GPU? | Notes |
|
||||
|----|------|-------|-----|----------|------|-------|
|
||||
| 118 | elementsynapse | 2 | 4 GiB | ❌ | ❌ | Matrix homeserver. Public via Caddy (`matrix.hubris.network`). Only change: Caddy backend IP. **Easiest move in the fleet.** |
|
||||
| 129 | house | 2 | 3 GiB | ❌ | ❌ | Yuvomi family planner (Docker). No public Caddy route yet (uses VPS traefik directly). Self-contained. |
|
||||
|
||||
**Combined RAM freed from hubris: 7 GiB.** No NFS, no library, no GPU.
|
||||
|
||||
### Tier 2 — Move with library NFS (high resource consumers)
|
||||
|
||||
These are the heaviest guests and the original migration plan's primary
|
||||
targets. They mount `/mnt/library` and two use the iGPU. Moving them
|
||||
requires an NFS export from hubris → strong (reverse of the original
|
||||
plan's direction, since the physical SSD hasn't moved).
|
||||
|
||||
| ID | Name | Cores | RAM | Library? | GPU? | I/O profile | Notes |
|
||||
|----|------|-------|-----|----------|------|-------------|-------|
|
||||
| 120 | mule-images | 6 | 12 GiB | ✅ mp0 | ✅ iGPU | Write-heavy (photo processing) | **#1 RAM consumer.** Strong has Radeon 680M iGPU (VAAPI works). |
|
||||
| 122 | arriman | 4 | 8 GiB | ✅ mp0 | ❌ | Write-heavy (downloads) | *arr stack + qbit + sab. Mounts library for download writes. |
|
||||
| 101 | jellyfin | 4 | 8 GiB | ✅ mp0 | ✅ iGPU | Read-heavy sequential | Media streaming + transcode. Strong 680M handles VAAPI. |
|
||||
|
||||
**Combined RAM freed: 28 GiB.** This alone would eliminate hubris's swap
|
||||
pressure entirely.
|
||||
|
||||
### Tier 3 — Could move, low urgency
|
||||
|
||||
| ID | Name | Cores | RAM | Library? | Notes |
|
||||
|----|------|-------|-----|----------|-------|
|
||||
| 130 | grimmory | 1 | 2 GiB | ✅ mp0 | Book library (Docker). Migrated from apps LXC recently. |
|
||||
| 131 | teddycloud | 1 | 1 GiB | ✅ mp0 | New (not in inventory.yaml yet). |
|
||||
| 132 | rclone | 1 | 2 GiB | ✅ mp0 (ro) | Backup container. Read-only library mount. |
|
||||
| 128 | trmnl | 1 | 768 MiB | ❌ | TRMNL middleware. No library. Could move but tiny. |
|
||||
| 119 | sophia | 2 | 1 GiB | ✅ mp0 | Workshop. Light use. |
|
||||
|
||||
### Stay on hubris (core infrastructure)
|
||||
|
||||
| ID | Name | Cores | RAM | Why it stays |
|
||||
|----|------|-------|-----|--------------|
|
||||
| 121 | caddy | 1 | 512 MiB | Reverse proxy — terminates all `*.hubris.network`. Must stay on hubris for LAN-side reachability. **Needs backend IP updates** when guests move. |
|
||||
| 107 | dns | 1 | 1 GiB | Technitium DNS, split-horizon. Core. |
|
||||
| 106 | auth-outpost | 1 | 512 MiB | Authentik SSO enforcement. Core. |
|
||||
| 105 | apps | 2 | 4 GiB | homelab MCP + secrets-issuance + artifacto. Core infra. Mounts library. |
|
||||
| 104 | gitea | 1 | 1 GiB | Git server. NFS would hurt git lock/stat perf. Mounts library (bare repos). |
|
||||
| 103 | paperless | 2 | 3 GiB | Document archive. Moderate I/O, OCR writes. Mounts library. |
|
||||
| 102 | nfs-export | 1 | 512 MiB | Exports library to zimaos via NFS. Must stay with the physical library. |
|
||||
| 100 | zimaos | 4 | 8 GiB (VM) | NAS frontend eval. Already NFS-mounts library from 102. |
|
||||
| 108 | haos | 2 | 4 GiB (VM) | Home Assistant OS. Hardware access, low latency. |
|
||||
|
||||
---
|
||||
|
||||
## Constraints & prerequisites
|
||||
|
||||
### 1. Network — strong needs an internal bridge (Phase 0)
|
||||
|
||||
strong currently has only `vmbr0` on `192.168.178.0/24`. Guests created there
|
||||
get household-LAN IPs, not homelab-subnet IPs. Two options:
|
||||
|
||||
- **Option A (recommended):** Add `vmbr1` on strong as a portless internal
|
||||
bridge with a `192.168.8.x/24` address (e.g. `192.168.8.3`). Route between
|
||||
strong's `vmbr0` and `vmbr1` the same way hubris does. Guests go on `vmbr1`
|
||||
and get `192.168.8.x` IPs — transparent to Caddy, DNS, and inter-LXC refs.
|
||||
Requires adding a static route on Fritz (or relying on hubris's existing
|
||||
route — strong would need IP forwarding + a route to 192.168.8.0/24 via vmbr1).
|
||||
|
||||
- **Option B (simpler, messier):** Put guests on `192.168.178.x` directly.
|
||||
Caddy can still reach them (hubris routes to 192.168.178.0/24). But DNS
|
||||
records, inter-LXC references, and firewall rules all assume `192.168.8.x`.
|
||||
More config churn per guest.
|
||||
|
||||
### 2. Storage — rootfs migration (no shared storage)
|
||||
|
||||
`local-lvm` is per-node (not shared). Moving an LXC requires either:
|
||||
- `vzdump` → restore on strong (clean, but needs temp disk space + downtime)
|
||||
- `rsync` the rootfs to a new LXC on strong (faster for large rootfs like 120's 100G)
|
||||
- `pct migrate` only works with shared storage — **not applicable here**
|
||||
|
||||
For VMs (100, 108): `qm migrate` also needs shared storage. Not moving VMs.
|
||||
|
||||
### 3. Library access — NFS from hubris to strong
|
||||
|
||||
Since the physical library SSD is still on hubris, strong's guests that need
|
||||
`/mnt/library` must NFS-mount it from hubris. Options:
|
||||
|
||||
- **Export from hubris host directly** (simplest): add `/mnt/library` to
|
||||
`/etc/exports` on hubris with the same squash params as LXC 102
|
||||
(`rw,all_squash,anonuid=33,anongid=10000,no_subtree_check`). Mount on strong
|
||||
at `/mnt/library`. Strong's guests bind-mount it just like hubris's guests do.
|
||||
|
||||
- **Use existing nfs-export LXC 102**: strong NFS-mounts from `192.168.8.200`
|
||||
(LXC 102). This already has the right squash config. Less host-level change.
|
||||
**This is the path of least resistance.**
|
||||
|
||||
### 4. GPU — iGPU passthrough on strong
|
||||
|
||||
strong has a Ryzen 7 PRO 6850U with Radeon 680M iGPU. For jellyfin (VAAPI
|
||||
transcoding) and mule-images (photo processing), we need:
|
||||
- `/dev/dri/renderD128` passed to the LXC (`lxc.cgroup2.devices.allow` +
|
||||
`lxc.mount.entry` or Proxmox's `dev0:` passthrough)
|
||||
- `video` / `render` group membership inside the container
|
||||
- Confirm `amdgpu` driver loads on strong's host kernel (it should — same APU family)
|
||||
|
||||
### 5. Quorum — 2-node cluster, no QDevice
|
||||
|
||||
Moving guests to strong does NOT fix the quorum issue but **reduces blast
|
||||
radius**: if hubris reboots (its known thermal instability), the guests on
|
||||
strong keep running independently. Consider adding a QDevice as a separate
|
||||
follow-up — it's orthogonal to this migration.
|
||||
|
||||
---
|
||||
|
||||
## Revised migration phases
|
||||
|
||||
The original plan's NFS-over-LAN approach has been superseded. Instead,
|
||||
**media library data moves to ludo-lvm** on strong so migrated guests access
|
||||
it as a local ext4 mount. Data is split by origin:
|
||||
|
||||
```
|
||||
hubris (stays): library SSD (3.7T, 1.2T used)
|
||||
└── /mnt/library/{documents,images,cloud,homecloud,notes,repos,sophia}
|
||||
↑ user-generated content (docs, photos, cloud sync, notes, repos, workshop)
|
||||
|
||||
strong (moves): ludo-lvm (1.8T, 0 used at start)
|
||||
└── /mnt/media_local ← 1.5T thin volume
|
||||
└── {downloads,movies,music,tv,anime,books}
|
||||
↑ non-user-generated content (media arr stack, book library)
|
||||
```
|
||||
|
||||
| Category | Stays on hubris | Moves to strong |
|
||||
|----------|----------------|-----------------|
|
||||
| Media | — | downloads (25G), movies (51G), music (29G), tv (30G), anime (206G) |
|
||||
| Books | — | books (2.6G) |
|
||||
| Docs/Photos | documents (249M), images (4K) | — |
|
||||
| Cloud sync | cloud (287G), homecloud (367G) | — |
|
||||
| Personal | notes (6.7M), repos (84M), sophia (151G) | — |
|
||||
| **Total** | **~805G** | **~344G** |
|
||||
|
||||
ludo-lvm (1.8T) fits all media + books with ~1.15T headroom for growth.
|
||||
hubris library SSD (3.7T, 1.2T used) retains the user-generated content.
|
||||
Both sides keep their data local — no cross-node NFS needed for daily I/O.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2a — Prepare ludo-lvm on strong
|
||||
|
||||
1. Create a ext4 filesystem on ludo-lvm for media:
|
||||
```bash
|
||||
lvcreate -n media -L 1.5T ludo-lvm
|
||||
mkfs.ext4 /dev/ludo-lvm/media
|
||||
```
|
||||
2. Mount at `/mnt/media_local` on strong, add to `/etc/fstab`
|
||||
3. rsync media directories from hubris → strong:
|
||||
```bash
|
||||
rsync -av --progress /mnt/library/{movies,tv,anime,downloads,music,books} strong:/mnt/media_local/
|
||||
```
|
||||
|
||||
### Phase 2b — Migrate arriman (122) to strong
|
||||
|
||||
1. Stop arriman on hubris, dump rootfs (24G)
|
||||
2. Restore on strong with IP `192.168.8.245/28` on vmbr1
|
||||
3. Mount `/mnt/media_local` → `/mnt/library` via mp0 (downloads land locally)
|
||||
4. Update Caddy: jellyseerr/qbit/sab backends → new IP
|
||||
5. Update inventory.yaml
|
||||
|
||||
### Phase 2c — Migrate jellyfin (101) to strong
|
||||
|
||||
1. Stop jellyfin on hubris, dump rootfs (16G)
|
||||
2. Restore on strong with IP `192.168.8.246/28` on vmbr1
|
||||
3. Pass `/dev/dri/renderD128` + `/dev/dri/card0` (Radeon 680M + RX 7600)
|
||||
4. Mount `/mnt/media_local` → `/mnt/library` via mp0 (media reads locally)
|
||||
5. Update Caddy: `media.hubris.network` → new IP
|
||||
6. Reinstall `sso-inject.js` in web dir (lost on every apt upgrade)
|
||||
7. Test VAAPI transcoding, SSO login, media playback
|
||||
|
||||
### Phase 2d — Migrate grimmory (130) to strong
|
||||
|
||||
1. Stop grimmory on hubris, dump rootfs (16G)
|
||||
2. Restore on strong with IP `192.168.8.247/28` on vmbr1
|
||||
3. Mount `/mnt/media_local` → `/mnt/library` via mp0 (books read locally)
|
||||
4. Update Caddy: `books.hubris.network` → new IP
|
||||
5. Update inventory.yaml
|
||||
6. Test: book browsing, calibre-web access
|
||||
|
||||
### No NFS export needed
|
||||
|
||||
With the data split by origin, hubris guests that only need user-generated
|
||||
content (documents, images, cloud, repos, sophia) still access them from the
|
||||
original library SSD — no cross-node NFS required. The two sides are
|
||||
independent.
|
||||
|
||||
**Result after Phase 2: hubris frees 26 GiB RAM (4 migrated guests) + 344G of
|
||||
library I/O burden. Strong becomes the media/books powerhouse.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Migrate mule-images (120) to strong
|
||||
|
||||
Move photo management (12 GiB RAM, 6 cores, iGPU) last because it needs:
|
||||
- `/mnt/library` access (now NFS from strong — already set up in Phase 2d)
|
||||
- `/dev/dri/renderD128` (Radeon 680M — confirm VAAPI compatibility first)
|
||||
|
||||
Steps:
|
||||
1. Stop mule-images on hubris, rsync the 100G rootfs to strong (faster than vzdump)
|
||||
2. Restore on strong with IP on vmbr1
|
||||
3. Pass Radeon 680M iGPU
|
||||
4. Reconfigure library paths → `/mnt/media_local` (or keep NFS mount)
|
||||
5. Update Caddy: `photos.hubris.network` → new IP
|
||||
6. Test photo import + processing pipeline
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Tier 3 moves (optional)
|
||||
|
||||
Migrate grimmory (130), teddycloud (131), rclone (132), trmnl (128), sophia (119)
|
||||
as needed — each frees 1–2 GiB. Not urgent; do when convenient.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Follow-up
|
||||
|
||||
- **QDevice**: add a tiebreaker for 2-node quorum
|
||||
- **Gaming VM**: strong's 6850U has enough cores alongside migrated LXCs
|
||||
- **Hubris library cleanup**: after all guests are confirmed working, decide
|
||||
whether to keep the original library SSD as backup or repurpose it
|
||||
|
||||
---
|
||||
|
||||
## Resource math after Phase 3 (all Tier 1 + 2 moved)
|
||||
|
||||
| | hubris | strong |
|
||||
|---|--------|--------|
|
||||
| Guests | 11 LXC + 2 VM | 5 LXC |
|
||||
| RAM allocated | ~25 GiB | ~45 GiB |
|
||||
| RAM capacity | 28 GiB | 28 GiB |
|
||||
| Overcommit | 0.9× (under-committed) | 1.6× (manageable) |
|
||||
| Library disk | Local ext4 (3.7T) → NFS client | Local ext4 on ludo-lvm (1.8T) |
|
||||
| GPU | Radeon 760M (idle) | Radeon 680M (jellyfin + mule-images) |
|
||||
|
||||
strong becomes the media/library powerhouse. hubris becomes a lean core-infra
|
||||
node (DNS, auth, git, docs, caddy, HA).
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Risk register
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| NFS latency for library reads (jellyfin, arriman) | Media playback stutter, slow downloads | Test iperf between strong↔hubris first. If 2.5G link, NFS throughput is fine (~1 Gbit/s). |
|
||||
| GPU passthrough on strong (680M vs 760M) | Transcode quality/compat differences | Both are AMD VAAPI — same driver stack. Test `vainfo` inside LXC before going live. |
|
||||
| Caddy backend IP churn | Service outage if IP wrong | Update Caddyfile in git repo (caddy-conf), test each route before destroying old LXC. |
|
||||
| vzdump/restore downtime | Service unavailable during migration | Schedule off-hours. Use rsync for large rootfs (120's 100G) to minimize freeze window. |
|
||||
| 2-node quorum still fragile | If hubris goes down, strong /etc/pve goes read-only | Guests keep running. Add QDevice as follow-up. |
|
||||
| Library data integrity during NFS transition | Permission drift | NFS `all_squash,anonuid=33,anongid=10000` matches existing LXC 102 config. Verify with `ls -la /mnt/library` after mount. |
|
||||
|
||||
---
|
||||
|
||||
## Open questions for operator
|
||||
|
||||
1. **Internal bridge on strong**: proceed with `vmbr1` on `192.168.8.3/24`
|
||||
(Option A), or use `192.168.178.x` guest IPs (Option B)?
|
||||
2. **Migration method**: `vzdump`/restore (clean, downtime) vs `rsync` rootfs
|
||||
(faster for large disks, needs manual config copy)?
|
||||
3. **Phase 1 priority**: move elementsynapse + house first (quick wins), or
|
||||
go straight to Phase 2 (mule-images/jellyfin/arriman) for maximum relief?
|
||||
4. **Should we add a QDevice now** before moving anything, to protect
|
||||
management plane during the migration?
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-05 — Phase 2d complete (grimmory migrated; media NFS to zimaos)
|
||||
grimmory (130) → 192.168.8.247 on strong. Rsync'd /books (2.6G) to ludo-lvm.
|
||||
LXC 102 (nfs-export) now mounts strong's NFS at /mnt/media and exports it as
|
||||
a second share alongside /mnt/library. Zimaos mounts both: /media/library
|
||||
(hubris user-generated) and /media/media (strong media+books).
|
||||
See hosts/strong.md changelog.
|
||||
|
||||
### 2026-07-05 — Phase 2 complete (arriman + jellyfin migrated; library on ludo-lvm)
|
||||
arriman (122) → 192.168.8.245, jellyfin (101) → 192.168.8.246. Created 1.5T
|
||||
thin volume on ludo-lvm, rsync'd 363G of media data. Both containers use local
|
||||
ext4 mount — no NFS. Jellyfin has 680M + RX 7600 GPU passthrough.
|
||||
Caddy backends updated. See hosts/strong.md changelog.
|
||||
|
||||
### 2026-07-05 — Phase 1 complete (elementsynapse + house migrated to strong)
|
||||
Both Tier 1 guests moved: elementsynapse (118) → 192.168.8.242, house (129) → 192.168.8.244.
|
||||
Strong now has vmbr1 at 192.168.8.241/28. Hubris has proxy ARP + /32 routes for strong
|
||||
guest range. DHCP scope narrowed to 192.168.8.100-239 to avoid conflicts.
|
||||
Teddycloud (LXC 131) given static IP 192.168.8.150 due to IP conflict with
|
||||
previous DHCP allocation at 192.168.8.243.
|
||||
See hosts/strong.md changelog for full steps.
|
||||
|
||||
### 2026-07-05 — assessment created
|
||||
Built from live `pct config` + `pvesm status` + `free -h` data pulled from
|
||||
both nodes. Supersedes the storage-migration framing of the original
|
||||
library-SSD plan — this assessment treats the SSD move as optional and
|
||||
focuses on guest relocation via NFS.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Glossary
|
||||
|
||||
Terms and abbreviations used throughout the homelab wiki.
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **Authentik** | SSO/identity provider. Core runs on the VPS; forward-auth outpost at LXC 106 on hubris |
|
||||
| **Caddy** | Reverse proxy (LXC 121). Terminates TLS for every `*.hubris.network` hostname |
|
||||
| **Caveman** | Terse communication standard for agent responses — no filler, keep substance |
|
||||
| **Forward-auth** | Caddy snippet that delegates authentication to an Authentik outpost. Protects web UIs like qBit, SABnzbd |
|
||||
| **Gitea** | Git server at `git.hubris.network`. Hosts all tracked config repos |
|
||||
| **Gluetun** | WireGuard VPN sidecar on arriman. All \*arr traffic routes through it |
|
||||
| **HAOS** | Home Assistant Operating System. VM 108 on hubris |
|
||||
| **Hubris** | Primary Proxmox VE node (GMKtec NucBox M6 Ultra). PVE hostname, cluster member 1 |
|
||||
| **LXC** | Linux Container (Proxmox). VM-like isolation without a full OS kernel |
|
||||
| **LVM-thin** | Thin-provisioned logical volume manager. Used for all container/VM storage |
|
||||
| **MCP** | Model Context Protocol (MCP server at `mcp.hubris.network`). Structured tools for agents to query homelab state |
|
||||
| **Mesh** | Overlay VPN for off-LAN connectivity. Netbird is current; Tailscale is legacy |
|
||||
| **Netbird** | Preferred mesh VPN. VPS hosts the management plane; all homelab nodes are members |
|
||||
| **OIDC** | OpenID Connect. Protocol used by Authentik for SSO login flows |
|
||||
| **Oikos** | Agent operating model ([.agents/OIKOS.md](../.agents/OIKOS.md)). OODA loop, risk classes, policy, ontology |
|
||||
| **PVE** | Proxmox Virtual Environment — the hypervisor on both hubris and strong |
|
||||
| **SOPS** | `sops` — Mozilla SOPS. Encrypts secrets with age keys so they live in the git repo |
|
||||
| **Strong** | Secondary Proxmox VE node. Cluster member 2 (hostname `strong`, nickname ludo/ludo-mini) |
|
||||
| **Traefik** | Reverse proxy on IONOS VPS. Serves `*.hubris.network` to the public internet |
|
||||
| **VAAPI** | Video Acceleration API. Intel/AMD GPU-based hardware transcode for Jellyfin |
|
||||
| **VPS** | Virtual Private Server at IONOS (`82.165.190.79`). Runs Authentik core + Netbird management |
|
||||
| **\\*arr** | Media automation suite: Sonarr (TV), Radarr (movies), Lidarr (music), Prowlarr (indexer), Bazarr (subtitles), Readarr (books — not in use) |
|
||||
|
||||
## See also
|
||||
|
||||
- [Infrastructure index](wiki/infrastructure/index.md) — cross-cutting systems each with their own doc page
|
||||
- [OIKOS operating model](../.agents/OIKOS.md) — agent policy, risk classes, lifecycle
|
||||
@@ -1,160 +0,0 @@
|
||||
# 101 — `jellyfin`
|
||||
|
||||
Media server: serves the movies / TV / anime / music libraries from `/mnt/media_local` to LAN clients. Hardware transcoding via AMD Radeon 680M + RX 7600 VAAPI. Authentik SSO via OIDC.
|
||||
|
||||
## At a glance
|
||||
- **Host:** **strong** (migrated from hubris 2026-07-05)
|
||||
- **IP:** `192.168.8.246`
|
||||
- **Privilege:** privileged (recreated on strong as priv)
|
||||
- **Resources:** 4 cores / 8 GiB RAM / 1 GiB swap / 16 GiB rootfs
|
||||
- **GPU:** `/dev/dri/renderD128` + `/dev/dri/card0` (AMD Radeon 680M iGPU + RX 7600 dGPU) passed via `dev0` / `dev1` in LXC config
|
||||
- **Mounts:** `/mnt/media_local` ↔ `/mnt/library`
|
||||
- **Public hostname:** [`media.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → `:8096`
|
||||
- **Version:** Jellyfin 10.11.11 (apt package, Ubuntu 24.04 noble repo)
|
||||
- **FFmpeg:** jellyfin-ffmpeg7 7.1.4
|
||||
|
||||
## Service / port map
|
||||
|
||||
| Service | Listen | Notes |
|
||||
| -------- | ------ | ----- |
|
||||
| jellyfin | `:8096` | HTTP (caddy terminates TLS) |
|
||||
|
||||
## Hardware acceleration (VAAPI)
|
||||
|
||||
GPU is passed through to the LXC via `dev0: /dev/dri/renderD128,gid=993` and
|
||||
`dev1: /dev/dri/card0,gid=44` in
|
||||
`/etc/pve/lxc/101.conf` (strong). The `jellyfin` user is in the `render` (GID 993) and
|
||||
`video` groups inside the container.
|
||||
|
||||
| GPU | Model | Role |
|
||||
|-----|-------|------|
|
||||
| Radeon 680M | iGPU (AMD Ryzen 7 PRO 6850U) | Primary VAAPI encoder/decoder |
|
||||
| RX 7600 | dGPU (add-in) | Secondary transcode, HEVC/AV1 encoding |
|
||||
|
||||
Encoding settings (`/etc/jellyfin/encoding.xml`):
|
||||
- `HardwareAccelerationType`: `vaapi`
|
||||
- `VaapiDevice`: `/dev/dri/renderD128`
|
||||
- `EnableHardwareEncoding`: `true`
|
||||
- `AllowHevcEncoding`: `true`
|
||||
- `AllowAv1Encoding`: `true`
|
||||
- `EnableTonemapping`: `true`
|
||||
- `HardwareDecodingCodecs`: h264, hevc, vc1, vp9, av1
|
||||
- `EnableThrottling`: `true`
|
||||
- `EnableSegmentDeletion`: `true`
|
||||
|
||||
Trickplay (`/etc/jellyfin/system.xml`):
|
||||
- `EnableHwAcceleration`: `true`
|
||||
- `EnableHwEncoding`: `true`
|
||||
|
||||
## Authentik SSO (OIDC)
|
||||
|
||||
Jellyfin uses the [SSO-Auth plugin](https://github.com/9p4/jellyfin-plugin-sso)
|
||||
v4.0.0.4 for Authentik OIDC login. No Caddy forward-auth gate — the SSO plugin
|
||||
handles auth directly via OIDC redirect flow.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
User → media.hubris.network → Caddy (TLS, no forward-auth) → Jellyfin :8096
|
||||
↓
|
||||
Login page with "Sign in with Authentik" button
|
||||
↓ (click)
|
||||
/sso/OID/start/Authentik
|
||||
↓ (302 redirect)
|
||||
auth.hubris.network OIDC
|
||||
↓ (login)
|
||||
/sso/OID/redirect/Authentik?code=...&state=...
|
||||
↓
|
||||
Jellyfin SSO plugin validates token → logged in
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **SSO-Auth plugin** — installed at `/var/lib/jellyfin/plugins/SSO-Auth_4.0.0.4/`
|
||||
- Config: `/var/lib/jellyfin/plugins/configurations/SSO-Auth.xml`
|
||||
- Provider name: `Authentik`
|
||||
- OIDC endpoint: `https://auth.hubris.network/application/o/jellyfin/`
|
||||
- `SchemeOverride`: `https` (required — without it, plugin generates
|
||||
`http://` redirect URIs that Authentik rejects)
|
||||
- `EnableAuthorization`: `false` (prevents plugin from overwriting admin
|
||||
permissions on each SSO login — see
|
||||
[jellyfin-sso-plugin](../../../devops/homelab-authentik-admin/references/jellyfin-sso-plugin.md))
|
||||
- `OidScopes`: `["email"]` (openid+profile added by default by the plugin;
|
||||
must be non-null or `OidChallenge()` throws `ArgumentNullException`)
|
||||
|
||||
2. **Authentik OIDC provider** — `Provider for Jellyfin` (PK 6)
|
||||
- Client ID: `vt61t5Y2ZVtN6l3QjitkBvwUJjFKvSyl4TDBXcJx`
|
||||
- Redirect URI: `https://media.hubris.network/sso/OID/redirect/Authentik`
|
||||
- Application slug: `jellyfin`
|
||||
|
||||
3. **SSO button injection** — `/usr/share/jellyfin/web/sso-inject.js`
|
||||
- Injected via `<script defer src="sso-inject.js?v=3">` in `index.html`
|
||||
- Polls for `.readOnlyContent` div on the login page, adds "Sign in with
|
||||
Authentik" button linking to `/sso/OID/start/Authentik`
|
||||
- Cache-busted with `?v=N` parameter (bump on changes)
|
||||
- **Lost on apt upgrade** — re-inject the script tag and copy the JS file
|
||||
after `apt-get upgrade jellyfin*`
|
||||
|
||||
4. **Caddy config** — no forward-auth gate for media.hubris.network:
|
||||
```caddy
|
||||
media.hubris.network {
|
||||
tls { dns ionos {env.IONOS_AUTH_API_TOKEN} }
|
||||
reverse_proxy 192.168.8.206:8096
|
||||
}
|
||||
```
|
||||
|
||||
### Known issues / pitfalls
|
||||
|
||||
- **`OidScopes` must be non-null** — if the field is missing from the plugin
|
||||
config XML, `OidChallenge()` throws `System.ArgumentNullException`. Always
|
||||
include `OidScopes` in the provider config (even if empty array).
|
||||
- **`SchemeOverride: "https"` is required** — without it, the plugin generates
|
||||
`http://` redirect URIs (from the internal HTTP listener). Authentik rejects
|
||||
them with "Redirect URI Error".
|
||||
- **SSO button JS is not served by the plugin** — the `__plugin/SSO-Auth.js`
|
||||
endpoint returns 404 on Jellyfin 10.11.x when the plugin is installed
|
||||
manually (not via Jellyfin's plugin manager). The `sso-inject.js` workaround
|
||||
in `index.html` is the fallback.
|
||||
- **No Caddy forward-auth gate** — the SSO plugin's OIDC redirect flow is
|
||||
incompatible with Caddy's `import authentik` forward-auth. If both are
|
||||
enabled, the forward-auth intercepts the OIDC callback and breaks the flow.
|
||||
Use one or the other, not both. SSO plugin (OIDC redirect) is preferred.
|
||||
- **API key for setup** — a temp API key can be inserted directly into the
|
||||
`ApiKeys` SQLite table for automated configuration:
|
||||
```sql
|
||||
INSERT INTO ApiKeys VALUES (1, '2026-07-04', '2026-07-04', 'setup', 'jf-setup-key-...');
|
||||
```
|
||||
|
||||
## Permissions
|
||||
Member of the [media GID 10000](../infrastructure/media-permissions.md) standard. Service user `jellyfin` is in the `media` group inside the container; `/mnt/media_local` on strong's ludo-lvm is owned `root:media` with mode `2775`.
|
||||
|
||||
## Related
|
||||
- [Caddy reverse proxy](121-caddy.md)
|
||||
- [Media permissions](../infrastructure/media-permissions.md)
|
||||
- [arriman](122-arriman.md) — \*arr stack writes the libraries jellyfin reads
|
||||
- [DNS split-horizon](../infrastructure/dns.md)
|
||||
- [Authentik admin](../../../devops/homelab-authentik-admin/SKILL.md) — OIDC provider creation, SSO plugin config
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-06 — wiki: IP, host, GPU, mount path updated for strong migration
|
||||
Updated At-a-glance: IP 206→246, host hubris→strong, mount /mnt/library→/mnt/media_local, GPU Radeon 760M→680M+RX7600, privilege unpriv→priv. Permissions section updated. Changelog entry in 122-arriman.md updated similarly.
|
||||
|
||||
### 2026-07-04 — VAAPI hardware acceleration + Authentik SSO + resource bump
|
||||
- Upgraded Jellyfin 10.11.8 → 10.11.11 (purge + reinstall to fix DB migration bug)
|
||||
- Enabled VAAPI hardware acceleration (Radeon 760M): h264/hevc/vc1/vp9/av1 decode + encode
|
||||
- Bumped resources: 2→4 cores, 4→8 GiB RAM, 512→1024 MiB swap
|
||||
- Enabled trickplay HW acceleration + throttling + segment deletion
|
||||
- Installed SSO-Auth plugin v4.0.0.4 with Authentik OIDC provider
|
||||
- Configured `SchemeOverride: https`, `OidScopes: ["email"]`, `EnableAuthorization: false`
|
||||
- Injected SSO button via `sso-inject.js` in web `index.html`
|
||||
- Removed Caddy forward-auth gate (incompatible with SSO plugin OIDC flow)
|
||||
- **Database was wiped** during cache relocation attempt — no LVM snapshot
|
||||
existed. All watch states, user accounts, and library configs lost.
|
||||
Libraries re-added via setup wizard.
|
||||
|
||||
### 2026-04-28 — wiki entry created
|
||||
Initial documentation. No config changes.
|
||||
|
||||
### 2026-04-20 — joined the `media` GID 10000 standard
|
||||
Idmap block applied; in-container `media` group at GID 10000 mapped to host GID 10000. See [media permissions](../infrastructure/media-permissions.md). Config backup: `/root/101.conf.bak.*`.
|
||||
@@ -1,56 +0,0 @@
|
||||
# 106 — `auth-outpost`
|
||||
|
||||
Authentik **forward-auth outpost** for LAN-gated apps. A stateless proxy that connects outbound to the [VPS Authentik core](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md) and serves forward-auth locally, so [Caddy (121)](121-caddy.md) never hairpins auth through VPS Traefik.
|
||||
|
||||
## At a glance
|
||||
- **Hostname:** `auth-outpost`
|
||||
- **IP:** `192.168.8.6` (static)
|
||||
- **Privilege:** privileged (Docker-in-LXC, `features: nesting=1`)
|
||||
- **Resources:** 1 core / 512 MiB / 4 GiB rootfs
|
||||
- **Mounts:** none
|
||||
- **Created:** 2026-06-01, Debian 13, replacing the embedded outpost on [124](106-auth-outpost.md)
|
||||
|
||||
## Role
|
||||
|
||||
Runs one container — `ghcr.io/goauthentik/proxy` — that opens an outbound websocket to `https://auth.hubris.network` (the VPS core), pulls its proxy-provider config, and answers Caddy's `forward_auth` subrequests on `192.168.8.6:9000` (LAN-only bind). Because the call path is **Caddy → outpost (LAN)**, with no Traefik in between, `X-Forwarded-Host` is preserved — the failure that 404s when Caddy is pointed at `https://auth.hubris.network` directly (Traefik rewrites the header). See the [migration investigation](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md).
|
||||
|
||||
## Service / port map
|
||||
| Service | Listen | Notes |
|
||||
| --- | --- | --- |
|
||||
| authentik proxy outpost | `192.168.8.6:9000` | `/outpost.goauthentik.io/*` (ping, auth/caddy) |
|
||||
|
||||
## Config paths
|
||||
- `/opt/authentik-outpost/docker-compose.yml` — `ghcr.io/goauthentik/proxy:2026.5.2`, `AUTHENTIK_HOST=https://auth.hubris.network`, `AUTHENTIK_INSECURE=false`, port bound `192.168.8.6:9000`.
|
||||
- `/opt/authentik-outpost/.env` (mode 600, **untracked**) — `AUTHENTIK_TOKEN` for outpost `hubris-lan-outpost` (sops-encrypt into `secrets/` — TODO Phase 5).
|
||||
|
||||
## The outpost object (VPS Authentik)
|
||||
- Outpost **`hubris-lan-outpost`** (type proxy), providers: `hubris-forward-auth` (domain, Paperless + domain-level apps), `Provider for Torrent` (qBittorrent), `Provider for Artifacto` (single).
|
||||
- Health: VPS admin → Applications → Outposts → last-seen current; `curl http://192.168.8.6:9000/outpost.goauthentik.io/ping` → `204`; container log `Successfully connected websocket`.
|
||||
|
||||
## Caddy wiring
|
||||
[Caddy (121)](121-caddy.md) `(authentik)` snippet `reverse_proxy`/`forward_auth` → `http://192.168.8.6:9000` (was `192.168.8.180:9000` on LXC 124). Tracked in `dtoro/caddy-conf`.
|
||||
|
||||
## `sso.hubris.network` — the callback domain (critical)
|
||||
Domain-level (`forward_domain`) providers redirect the browser to `{external_host}/outpost.goauthentik.io/callback` after login. On LXC 124 that `external_host` was `auth.hubris.network`, which resolved (LAN) to Caddy → the 124 outpost — same box, so it worked. Post-migration `auth.hubris.network` points to the **VPS core**, so the callback hit the wrong outpost → **400 / redirect-uri mismatch**.
|
||||
|
||||
Fix: the LAN outpost gets its **own** domain.
|
||||
- DNS: `sso.hubris.network → 192.168.8.175` (Caddy). *(in dnsmasq today — must be carried into Technitium in DNS Phase 2.)*
|
||||
- Caddy: site `sso.hubris.network { tls dns ionos; reverse_proxy 192.168.8.6:9000 }` (in `caddy-conf`).
|
||||
- Authentik: `hubris-forward-auth` and `Provider for Torrent` have `external_host=https://sso.hubris.network`; their `redirect_uris` must match (`set_oauth_defaults()` regenerates them from `external_host` — changing `external_host` alone does NOT update them).
|
||||
- `Provider for Artifacto` is `forward_single` on its own domain, so it was unaffected.
|
||||
|
||||
**Lesson:** when the IdP core and the forward-auth outpost live on different hosts, the outpost needs a dedicated domain distinct from the core's — and proxy-provider `redirect_uris` must be regenerated, not just `external_host`.
|
||||
|
||||
## Related
|
||||
- [124 — authentik](106-auth-outpost.md) — old embedded-outpost host (now DNS-only)
|
||||
- [Caddy (121)](121-caddy.md) — forward-auth consumer
|
||||
- [Ingress (VPS traefik)](../infrastructure/ingress.md)
|
||||
- [Authentik VPS migration](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-06 — Authentik session lifetime extended to 30 days
|
||||
VPS Authentik core `user_login` stage updated: `session_duration` changed from `seconds=0` (session cookie, cleared on browser close) to `days=30` (persistent 30-day cookie). Also set `AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE=days=30` in `/opt/authentik.env` on the VPS. See [investigation](../../sources/investigations/2026-06-06-authentik-session-lifetime.md).
|
||||
|
||||
### 2026-06-01 — created; forward-auth cut over from LXC 124
|
||||
New dedicated LXC for the LAN forward-auth outpost (Phase 1 of the [architecture migration](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md)). Deployed `goauthentik/proxy:2026.5.2` pointed at the VPS core; repointed Caddy `(authentik)` from `192.168.8.180:9000` → `192.168.8.6:9000`. Verified Paperless/qBittorrent/Artifacto return the SSO redirect with **124-Authentik stopped**, confirming the frozen instance is out of the path. dnsmasq stays on 124 until [DNS is relocated](106-auth-outpost.md).
|
||||
@@ -1,67 +0,0 @@
|
||||
# 107 — `dns`
|
||||
|
||||
Homelab DNS server (Technitium). Replaces the dnsmasq that lived on [124 — authentik](106-auth-outpost.md); single-purpose, one job.
|
||||
|
||||
## At a glance
|
||||
- **Hostname:** `dns`
|
||||
- **IP:** `192.168.8.2` (static — stable, decoupled from any app)
|
||||
- **Privilege:** privileged (Docker-in-LXC, `features: nesting=1`)
|
||||
- **Resources:** 1 core / 1 GiB / 8 GiB rootfs
|
||||
- **Created:** 2026-06-01, Debian 13. Its own resolver is `1.1.1.1` (no circular dependency on the DNS it serves).
|
||||
|
||||
## Role
|
||||
Authoritative split-horizon DNS for `hubris.network` on the LAN/mesh, plus recursive forwarding (`1.1.1.1`, `8.8.8.8`) for everything else. Technitium runs in Docker (`technitium/dns-server:latest`, `network_mode: host`), web console on `:5380`.
|
||||
|
||||
## The `hubris.network` zone
|
||||
- Specific A overrides: app names → `192.168.8.175` (Caddy), `nfs-export → 192.168.8.200`, `auth/sso/...` as needed.
|
||||
- `auth.hubris.network → 82.165.190.79` (VPS Authentik), `sso.hubris.network → 192.168.8.175` (LAN forward-auth outpost).
|
||||
- **Wildcard** `*.hubris.network → 82.165.190.79` — mirrors the public IONOS wildcard so undefined names (e.g. `netbird`) resolve to the VPS, matching public behaviour.
|
||||
- **MX / SPF-TXT / CAA replicated from public** so an authoritative zone doesn't shadow `hubris.network` email/cert records.
|
||||
|
||||
## Config / access
|
||||
- `/opt/technitium/docker-compose.yml`; admin password in `/opt/technitium/admin_password.txt` (mode 600 — sops-encrypt in Phase 5).
|
||||
- Console: `http://192.168.8.2:5380` (user `admin`).
|
||||
- API: `http://192.168.8.2:5380/api/...` (token via `/api/user/login`). Zone was built via the API.
|
||||
|
||||
## Who points here
|
||||
- **NetBird mesh peers:** resolve via the **NetBird managed DNS zone**, kept in sync *from* this Technitium (see dns-sync below). The `home-lab-dns` nameserver group (`→ 192.168.8.2`) is a thin fallback forwarder.
|
||||
- **Homelab DHCP clients:** Technitium's own DHCP scope hands out `192.168.8.2` as the DNS server for `192.168.8.x` leases (see DHCP section below).
|
||||
- **Plain LAN clients (`192.168.178.x`):** Fritz!Box DHCP still hands out Fritz!Box itself (`192.168.178.1`) as DNS — no split-horizon for non-mesh clients. Changing this requires a secondary DNS fallback, which Fritz!OS 8.x doesn't expose in a single DHCP field.
|
||||
|
||||
## dns-sync (Technitium = authoring source)
|
||||
`/opt/dns-sync/sync.py` (cron `*/10`, logs `/var/log/dns-sync.log`) reconciles this zone's named A-records → the NetBird managed DNS zone via the NetBird API (`/api/dns/zones/{id}/records`). Token at `/opt/dns-sync/netbird-token` (mode 600; source of truth in sops `secrets/netbird-pat.yaml`). **Edit DNS only here**; the sync propagates to the mesh. It deletes NetBird records absent from Technitium. Tracked: [scripts/dns-sync.py](../../../scripts/dns-sync.py). *Why this exists:* NetBird won't forward to Technitium for mesh peers (self-IP / nameserver-group quirks), so we sync into the managed zone instead — see [dns.md](../infrastructure/dns.md).
|
||||
|
||||
## DHCP
|
||||
|
||||
Technitium also runs a DHCP server for the homelab subnet (enabled 2026-06-02):
|
||||
- **Scope:** `homelab` — `192.168.8.241 – 192.168.8.254`
|
||||
- **Gateway:** `192.168.8.1` (Proxmox `vmbr0` alias)
|
||||
- **DNS:** `192.168.8.2` (self)
|
||||
- **Lease time:** 24 h
|
||||
|
||||
Replaces the DHCP that was previously served by the Slate AX router. Static-IP LXCs (`.101–.239`) are excluded from the pool. Pool narrowed from `.100–.240` to `.241–.254` on 2026-06-03 to eliminate IP conflict risk.
|
||||
|
||||
## Related
|
||||
- [124 — authentik](106-auth-outpost.md) — retired host of the old dnsmasq
|
||||
- [DNS split-horizon](../infrastructure/dns.md)
|
||||
- [Mesh](../infrastructure/mesh.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-24 — A record `trmnl.hubris.network → 192.168.8.175`
|
||||
Added for [trmnl (128)](128-trmnl.md) (LAN path via [Caddy (121)](121-caddy.md)); propagated to the NetBird managed zone by `dns-sync`.
|
||||
|
||||
### 2026-06-06 — dns-sync cron installed (had been missing since deployment)
|
||||
Although the 2026-06-03 changelog claimed "cron */10", **no crontab was actually configured** on the LXC. The sync was running only via ad-hoc manual invocations during incident debugging. Fixed by adding `/etc/cron.d/dns-sync`.
|
||||
|
||||
### 2026-06-03 — DHCP pool narrowed to `.241–.254`
|
||||
Previous pool `.100–.240` overlapped with all static LXCs/VMs (`.101–.239`). Shrunk via API (`/api/dhcp/scopes/set`). 11 stale DHCP leases in `.101–.110` remain until natural expiry (2026-06-04). See [plan](../../../.hermes/plans/2026-06-03_223218-dhcp-pool-exclude-static-ips.md).
|
||||
|
||||
### 2026-06-03 — dns-sync added (Technitium → NetBird managed zone)
|
||||
This Technitium became the single DNS authoring source; `/opt/dns-sync/sync.py` (cron */10) reconciles named A-records into the NetBird managed zone via the API. Fixed previously-broken mesh names (`sso`, `nfs-export`, `mcp`, `secrets`) by adding them to the managed zone; reaped obsolete `files`/`photos-new`. See [dns.md](../infrastructure/dns.md).
|
||||
|
||||
### 2026-06-02 — DHCP server enabled; replaces Slate AX DHCP
|
||||
Enabled Technitium's built-in DHCP server for `192.168.8.0/24` (scope `homelab`, range `.100–.240`, gateway `192.168.8.1`, DNS self). Previously the Slate AX sub-router served DHCP for the homelab subnet. With the Slate AX retired and Proxmox now the subnet router, Technitium takes over DHCP. Configured via the Technitium API (`/api/dhcp/scopes/set`). DHCP LXCs kept their Slate AX leases until expiry, then renewed from Technitium.
|
||||
|
||||
### 2026-06-01 — created; replaced dnsmasq on 124
|
||||
Stood up Technitium at `192.168.8.2`, imported the split-horizon zone (specific A + wildcard + MX/SPF/CAA), made it the primary nameserver in the NetBird `home-lab-dns` group. Verified all names resolve with dnsmasq/124 stopped; [LXC 124 retired](106-auth-outpost.md).
|
||||
@@ -1,48 +0,0 @@
|
||||
# 128 — `trmnl`
|
||||
|
||||
Self-hosted middleware for TRMNL e-ink plugins. TRMNL cloud polls it; it fetches/shapes live data into JSON the plugin's Liquid template renders.
|
||||
|
||||
## At a glance
|
||||
- **Hostname:** `trmnl`
|
||||
- **IP:** `192.168.8.211`
|
||||
- **Privilege:** unprivileged
|
||||
- **Resources:** 1 core / 768 MiB RAM / 8 GiB rootfs (Debian 13)
|
||||
- **Mounts:** none
|
||||
- **Public hostname:** `trmnl.hubris.network` (via [VPS ingress](../infrastructure/ingress.md))
|
||||
|
||||
## Role
|
||||
Runs one FastAPI aggregator (`server.app:app`, port 9851) that mounts a router per plugin from the `dtoro/terminalito` repo. First consumer: `munich-home` (`/munich-home/dashboard`) — weather (Open-Meteo), MVG transit, Google Calendar, plus server-side Kita/quote logic. Talks out to the public internet for those APIs; TRMNL cloud polls it inbound every 15 min. Bearer-token gated (`TRMNL_POLL_TOKEN`); `/health` is open.
|
||||
|
||||
## Service / port map
|
||||
| Service | Listen | Notes |
|
||||
|---------|--------|-------|
|
||||
| `trmnl-plugins` | `0.0.0.0:9851` | uvicorn aggregator; `EnvironmentFile=/etc/trmnl-plugins/env` |
|
||||
|
||||
## Storage / config paths
|
||||
- `/opt/terminalito` — git checkout (origin = internal gitea `http://192.168.8.121:3000/dtoro/terminalito.git`)
|
||||
- `/opt/terminalito/server/.venv` — venv
|
||||
- `/etc/trmnl-plugins/env` — `TRMNL_POLL_TOKEN` (+ Google/MVG creds once enrolled)
|
||||
- `/etc/systemd/system/trmnl-plugins.service`
|
||||
|
||||
## Auto-deploy
|
||||
Wired — [auto-deploy](../infrastructure/auto-deploy.md) Shape B, webhook id 12 on `dtoro/terminalito` → `http://192.168.8.211:9797/deploy` (`terminalito-deploy.service`). Push to `main` → `server/deploy/deploy.sh` (`git pull` + pip + reinstall units + restart `trmnl-plugins`). Secret `/etc/terminalito-deploy/secret`; git creds `/etc/terminalito-deploy/git-credentials` wired as a repo-local `credential.helper`. Manual: `pct exec 128 -- /opt/terminalito/server/deploy/deploy.sh`.
|
||||
|
||||
## Secrets
|
||||
Not yet SOPS-enrolled. The poll token is set directly in `/etc/trmnl-plugins/env`. Google Calendar + MVG creds are pending: enroll via `homelab client add trmnl` + bootstrap, add `secrets/trmnl-oauth.yaml`, then `server/deploy/render-env.sh` builds the env from `homelab secret trmnl-oauth`. Until then calendar/transit cards degrade to empty; weather works.
|
||||
|
||||
## Related
|
||||
- [Caddy (121)](121-caddy.md) — LAN reverse proxy (`trmnl.hubris.network → 192.168.8.211:9851`)
|
||||
- [VPS ingress](../infrastructure/ingress.md) — public edge (cert mirror + traefik router)
|
||||
- [DNS (107)](107-dns.md) — Technitium A record `trmnl → 192.168.8.175` (LAN path via Caddy)
|
||||
- [Gitea (104)](104-gitea.md) — source repo `dtoro/terminalito`
|
||||
- [Plan: 2026-06-24 TRMNL plugins LXC](../../../plans/2026-06-24-trmnl-plugins-lxc.md)
|
||||
|
||||
## Changelog
|
||||
### 2026-06-24 — auto-deploy + LAN DNS wired
|
||||
Gitea Shape-B deploy pipeline (webhook id 12, `:9797`) — push to `dtoro/terminalito` redeploys; verified end-to-end. Technitium A record `trmnl.hubris.network → 192.168.8.175` added on [dns (107)](107-dns.md) (propagated to the NetBird managed zone via dns-sync), so LAN clients take the short path through [Caddy (121)](121-caddy.md). See [auto-deploy](../infrastructure/auto-deploy.md).
|
||||
|
||||
### 2026-06-24 — public path live
|
||||
Verified end-to-end from the internet: `https://trmnl.hubris.network/munich-home/dashboard` → 200 with token, 401 without; `/health` 200. The provision-time outage was the netbird `home-lab-network` (192.168.8.0/24) route having no active routing peer — the **mac-mini routing peer's netbird daemon was down** (artifacto/blog were 504 too). Bringing netbird up on mac-mini restored the route; the edge recovered with no config change. See [ingress](../infrastructure/ingress.md) / [mesh](../infrastructure/mesh.md).
|
||||
|
||||
### 2026-06-24 — provisioned
|
||||
LXC 128 created (Debian 13, unprivileged, `192.168.8.211`). Deployed `trmnl-plugins.service` on :9851 from `dtoro/terminalito`. Caddy block added (`dtoro/caddy-conf`) + LE cert via IONOS DNS-01; verified `/health` 200 and `/munich-home/dashboard` (live weather) through Caddy. Cert mirrored to VPS (`trmnl.fullchain.crt`/`trmnl.privkey.key`) + traefik router `trmnl-public` → `192.168.8.211:9851` added to `/opt/traefik-dynamic.yaml`. **Public path pending**: VPS↔home netbird route was down at provision time (`No networks available`, 3/6 peers — artifacto/blog also 504); resolves when the mesh route recovers. **LAN pending**: Technitium A record not yet added. Not SOPS-enrolled; Google/MVG creds pending.
|
||||
@@ -1,52 +0,0 @@
|
||||
# 129 — `house`
|
||||
|
||||
Yuvomi family planner (formerly Oikos). Self-hosted family planner with 14 modules: calendar, tasks, meals, groceries, budget, documents, notes, contacts, birthdays, housekeeping, recipes, reminders.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Hostname:** `house`
|
||||
- **IP:** `192.168.8.244`
|
||||
- **Host:** **strong** (migrated from hubris 2026-07-05)
|
||||
- **Privilege:** unprivileged
|
||||
- **Resources:** 1 core / 1344 MiB RAM / 8 GiB rootfs (Debian 13)
|
||||
- **Mounts:** none
|
||||
- **Public hostname:** [`house.hubris.network`](../infrastructure/ingress.md) → VPS traefik → Caddy
|
||||
|
||||
## Service / port map
|
||||
|
||||
| Service | Listen | Notes |
|
||||
|---------|--------|-------|
|
||||
| `oikos` (Yuvomi) | `0.0.0.0:3000` | Docker Compose at `/opt/yuvomi/`, image `ghcr.io/ulsklyc/yuvomi` |
|
||||
|
||||
## Integrations
|
||||
|
||||
- **Authentik SSO (OIDC):** Provider `Provider for Yuvomi` (PK 31) in Authentik on VPS. Env vars in `/opt/yuvomi/.env`: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`. Redirect URI: `https://house.hubris.network/auth/oidc/callback`.
|
||||
- **Paperless DMS connector (native):** Yuvomi connects directly to Paperless-ngx API at `http://192.168.8.130:8000/`. API token stored in SQLite `dms_accounts` table. Search, link, and upload documents from Yuvomi to Paperless via Settings → Documents → DMS.
|
||||
- **Weather widget:** Open-Meteo (free, no API key). Munich coordinates set.
|
||||
- **Google Calendar:** OAuth configured via env vars (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`). Redirect URI: `https://house.hubris.network/api/v1/calendar/google/callback`. Authorize in Settings → Calendar → Connect Google Calendar.
|
||||
|
||||
## Config paths
|
||||
|
||||
- `/opt/yuvomi/docker-compose.yml` — downloaded from upstream
|
||||
- `/opt/yuvomi/.env` — config including secrets (untracked)
|
||||
- `/opt/yuvomi/data/` — SQLCipher SQLite DB (`oikos.db`)
|
||||
- `/opt/yuvomi/backups/` — auto backups
|
||||
- `/opt/yuvomi/modules/` — Yuvomi modules (empty for now)
|
||||
|
||||
## Related
|
||||
|
||||
- [Caddy (121)](121-caddy.md) — LAN reverse proxy (`house.hubris.network → 192.168.8.212:3000`)
|
||||
- [VPS ingress](../infrastructure/ingress.md) — public edge (cert mirror + traefik router)
|
||||
- [DNS (107)](107-dns.md) — Technitium A record `house → 192.168.8.175` (LAN path via Caddy)
|
||||
- [Paperless (103)](103-paperless.md) — native DMS connector (API at `:8000`)
|
||||
- [TRMNL (128)](128-trmnl.md) — Google Calendar tokens source
|
||||
- [Deployment plan](../../../plans/done/2026-06-25-yuvomi-deployment.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-27 — Google Calendar OAuth env vars configured
|
||||
`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` set in `.env`. New OAuth client ID (`-bho4iq..`).
|
||||
|
||||
### 2026-06-26 — provisioned
|
||||
|
||||
LXC 129 created (Debian 13, unprivileged, `192.168.8.212`). Docker installed. Yuvomi container running on `:3000` from `ghcr.io/ulsklyc/yuvomi:latest`. Caddy block + DNS A record + VPS traefik router `house-public` for public access. Authentik OIDC provider created (PK 31). WebDAV document bridge on paperless LXC (103) at `:8088` for Paperless auto-import.
|
||||
@@ -1,66 +0,0 @@
|
||||
# 130 — `grimmory`
|
||||
|
||||
Self-hosted digital library (eBooks, comics, audiobooks). Community fork/successor of Booklore, with smart shelves, metadata enrichment, Kobo/KOReader sync, OPDS, and a built-in EPUB/PDF reader. Migrated from [apps (105)](105-apps.md) on 2026-06-29.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Hostname:** `grimmory`
|
||||
- **IP:** `192.168.8.247`
|
||||
- **Host:** **strong** (migrated from hubris 2026-07-05)
|
||||
- **Privilege:** privileged (UID = host UID for `/mnt/library` media GID)
|
||||
- **Resources:** 1 core / 2 GiB RAM / 16 GiB rootfs (Debian 13)
|
||||
- **Mounts:** `/mnt/media_local` ↔ `/mnt/library`
|
||||
- **Public hostname:** `books.hubris.network`
|
||||
|
||||
## Service / port map
|
||||
|
||||
| Service | Listen | Notes |
|
||||
|---------|--------|-------|
|
||||
| Grimmory | `192.168.8.247:6060` | Docker Compose at `/opt/grimmory/` |
|
||||
| MariaDB | internal only | Sidecar in the same compose stack |
|
||||
|
||||
## Compose
|
||||
|
||||
Located at `/opt/grimmory/docker-compose.yml`. Key points:
|
||||
|
||||
- Image: `ghcr.io/grimmory-tools/grimmory:latest`
|
||||
- Books library: `/mnt/library/books` → `/books` (read-write; media GID 10000 via `GROUP_ID=10000`)
|
||||
- Bookdrop (watched folder for auto-import): `/opt/grimmory/bookdrop` → `/bookdrop`
|
||||
- App data (covers, DB config): `/opt/grimmory/data` → `/app/data`
|
||||
- MariaDB config: `/opt/grimmory/mariadb/config` → `/config` (linuxserver/mariadb image)
|
||||
- `extra_hosts: auth.hubris.network:192.168.8.175` — routes Authentik OIDC discovery to Caddy from inside the container
|
||||
- `FORCE_DISABLE_OIDC=false` — OIDC stays enabled; provider configured via Grimmory admin UI
|
||||
|
||||
Credentials live in `/opt/grimmory/.env` (untracked):
|
||||
- `DATABASE_PASSWORD` / `MYSQL_PASSWORD` — MariaDB Grimmory user password
|
||||
- `MYSQL_ROOT_PASSWORD` — MariaDB root password
|
||||
|
||||
## Authentik OIDC
|
||||
|
||||
Uses Confidential client (client secret stored in Grimmory's DB — migrated from Booklore). The OIDC config carried over in the database dump; no manual re-entry needed.
|
||||
|
||||
- **Authentik provider:** `Provider for Grimmory` (renamed from `Provider for Booklore` on migration)
|
||||
- **Client ID:** `L1u0eFsNhbKgiIvvFeIr2mvZdbtFyzidCq2h6thL`
|
||||
- **Client type:** Confidential (client secret in `oidc_provider_details` in MariaDB `app_settings`)
|
||||
- **Redirect URI:** `https://books.hubris.network/oauth2-callback`
|
||||
- **Scopes:** openid, profile, email, offline_access
|
||||
- **Back-channel logout:** `http://192.168.8.247:6060/api/v1/auth/oidc/backchannel-logout`
|
||||
- **Application slug:** `booklore` → Issuer URI: `https://auth.hubris.network/application/o/booklore/`
|
||||
|
||||
## Media permissions
|
||||
|
||||
LXC is privileged → in-container UID = host UID. Docker container gets media GID via `GROUP_ID=10000` env var (Grimmory/linuxserver pattern). The `/mnt/library/books` subtree is owned `:media` mode `2775` (setgid). See [media-permissions](../infrastructure/media-permissions.md).
|
||||
|
||||
## Related
|
||||
|
||||
- [apps (105)](105-apps.md) — previous host (Booklore)
|
||||
- [Caddy (121)](121-caddy.md) — `books.hubris.network → 192.168.8.247:6060`
|
||||
- [Authentik (124)](106-auth-outpost.md) — OIDC provider `Grimmory`
|
||||
- [DNS (107)](107-dns.md) — `books.hubris.network → 192.168.8.175` (unchanged from Booklore)
|
||||
- [Media permissions](../infrastructure/media-permissions.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-29 — provisioned; Booklore migrated
|
||||
|
||||
LXC 130 created (Debian 13, privileged, `192.168.8.213`). Docker installed. Grimmory compose deployed at `/opt/grimmory/`. MariaDB dump from Booklore (LXC 105) restored — schema-compatible since Grimmory is a direct fork. Caddy `books.hubris.network` backend updated from `192.168.8.205:6060` to `192.168.8.213:6060`. Authentik provider updated: Booklore → Grimmory, Confidential → Public (PKCE). Booklore stack removed from Portainer on LXC 105.
|
||||
@@ -1,62 +0,0 @@
|
||||
# 131 — `teddycloud`
|
||||
|
||||
Self-hosted [TeddyCloud](https://github.com/toniebox-reverse-engineering/teddycloud), a
|
||||
reimplementation of the Toniebox cloud backend — lets Tonie figurines play custom/ripped
|
||||
audio content against a local server instead of the official cloud.
|
||||
|
||||
Predates the client-enrollment convention entirely; nobody wrote it down. Found and
|
||||
documented on 2026-07-06 after Oikos's drift detector (`oikos/drift.py`) flagged
|
||||
`pve_id 131` as live on hubris (via `pct list`) with no `inventory.yaml` entry — see
|
||||
[OIKOS.md](../../../.agents/OIKOS.md)'s Week 3 build-status note. `containers/132-rclone.md` had already
|
||||
mentioned it in passing ("LXC 131 was already taken by an undocumented `teddycloud`
|
||||
container"), and `hosts/strong.md`'s 2026-07-05 migration changelog fixed a DHCP conflict
|
||||
for it — but it never got its own inventory entry or doc page until now.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Hostname:** `teddycloud`
|
||||
- **Host:** hubris (confirmed via `pct config 131` run directly on hubris — the original
|
||||
drift finding's `pct list` source)
|
||||
- **IP:** `192.168.8.150` (static; was briefly `192.168.8.243` via DHCP until the
|
||||
2026-07-05 strong-migration work assigned it a fixed address — see hosts/strong.md)
|
||||
- **Privilege:** unconfirmed (not checked — read-only investigation didn't need it)
|
||||
- **Resources:** 1 core / 1 GiB RAM / 512 MiB swap / 16 GiB rootfs (`local-lvm`), Debian 12
|
||||
(bookworm)
|
||||
- **Mounts:** `/mnt/library` (`mp0`)
|
||||
- **Public hostname:** `teddy.hubris.network` → `192.168.8.150:8443` (see
|
||||
[caddy (121)](121-caddy.md))
|
||||
- **Enrollment:** none — no `age_pubkey`, not a `homelab` CLI client. It's a plain
|
||||
docker-compose app container, not a fleet-managed host. No action needed unless it starts
|
||||
needing secrets.
|
||||
|
||||
## Service
|
||||
|
||||
Runs via `docker compose` at `/opt/teddycloud` — container `teddycloud-teddycloud-1`,
|
||||
image `ghcr.io/toniebox-reverse-engineering/teddycloud:latest`, publishing `80`, `443`,
|
||||
`8080`, and `8443`. Caddy routes `teddy.hubris.network` to the `:8443` port.
|
||||
|
||||
## Risk notes
|
||||
|
||||
**No Caddy forward-auth gate** — unlike `sab.hubris.network` on the same Caddyfile (which
|
||||
is explicitly annotated `(Authentik forward-auth)`), `teddy.hubris.network` has no auth
|
||||
annotation. It's reachable to anyone on the LAN/mesh who can resolve the hostname. Not
|
||||
addressed as part of this doc pass — flagging it here since it's now visible in one place
|
||||
for the first time.
|
||||
|
||||
## Related
|
||||
|
||||
- [Hubris host](../hosts/hubris.md)
|
||||
- [Caddy (121)](121-caddy.md) — terminates `teddy.hubris.network`
|
||||
- [rclone (132)](132-rclone.md) — landed on pve_id 132 specifically because 131 was already
|
||||
taken by this container
|
||||
- [Containers index](index.md)
|
||||
- [OIKOS.md](../../../.agents/OIKOS.md) — drift detector that caught this
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-06 — documented for the first time (drift-caught)
|
||||
|
||||
Added to `inventory.yaml` and given this page. Verified live via read-only `pct config 131`
|
||||
+ `pct exec 131 -- ...` on hubris: hostname, IP, resources, and that it runs via
|
||||
`docker compose` (not a raw binary or systemd unit). No changes made to the running
|
||||
container — this is pure documentation catch-up.
|
||||
@@ -1,199 +0,0 @@
|
||||
# 132 — `rclone`
|
||||
|
||||
Off-host backup appliance. Mirrors selected `/mnt/library` folders to **Proton Drive**
|
||||
with a plain `rclone sync` (monthly), and serves rclone's Web GUI on the LAN for browsing
|
||||
and ad-hoc runs. **Replaces** the disabled restic-on-USB job — see [backups](../infrastructure/backups.md).
|
||||
|
||||
Provisioned 2026-07-01. (LXC 131 was already taken by an undocumented `teddycloud` container,
|
||||
so this landed on **132**.)
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Hostname:** `rclone`
|
||||
- **IP:** `192.168.8.214` (static, set in PVE `net0` config — same pattern as grimmory/authentik)
|
||||
- **Privilege:** privileged (root in-container = host root → reads every `/mnt/library` subtree,
|
||||
incl. `homecloud/` and `documents/`, regardless of owner)
|
||||
- **Resources:** 1 core / 2 GiB RAM / 8 GiB rootfs (Debian 13) — bumped from 1 GiB on 2026-07-03
|
||||
after `rclone-rcd.service` was OOM-killed under real load (see "Known issue" below)
|
||||
- **Mounts:** `/mnt/library` **read-only** (`mp0: /mnt/library,mp=/mnt/library,ro=1`) — a backup
|
||||
job must never be able to write into the library
|
||||
- **Public hostname:** none — the UI is **LAN-only, no auth** (by design)
|
||||
|
||||
## Service / port map
|
||||
|
||||
| Service | Listen | Notes |
|
||||
|---------|--------|-------|
|
||||
| rclone Web GUI (`rcd`) | `192.168.8.214:5572` | `rclone-rcd.service`, **`--rc-no-auth`**, LAN-only. Browse `/mnt/library` + `proton:`, run ad-hoc syncs, live job status |
|
||||
| monthly mirror | — | `rclone-backup.service` + `.timer` (`OnCalendar=*-*-01 03:00`) |
|
||||
|
||||
## Backup design
|
||||
|
||||
- **Mode:** plain mirror — `rclone sync` (Proton mirrors local; deletions propagate; **no versioning**).
|
||||
- **Encryption:** Proton Drive's built-in E2E only (no rclone `crypt` overlay → files stay
|
||||
browsable in Proton's web UI).
|
||||
- **Selected set:** `/etc/rclone-backup/folders.list` — one absolute source path per line
|
||||
(`#`/blank ignored). This file *is* the picked set the monthly timer mirrors. Extensible to other
|
||||
disks once bind-mounted into this LXC.
|
||||
- **Path mapping:** source `S` → `proton:library-backup/<S without leading slash>`
|
||||
(e.g. `/mnt/library/notes` → `proton:library-backup/mnt/library/notes`).
|
||||
- **Runner:** `/usr/local/sbin/rclone-backup.sh [folder ...]` (Python, despite the `.sh` name — kept
|
||||
the path stable) — no arg = every enabled line. Submits each folder as an **async job through the
|
||||
rclone rc API** served by `rclone-rcd.service` (the same daemon backing the Web GUI on `:5572`),
|
||||
so scheduled/ad-hoc runs show up live in the GUI's **Jobs panel**, not just in logs. Gentle on
|
||||
Proton's rate limits (`Transfers=4, TPSLimit=8, FastList=true` via the rc `_config` payload). The
|
||||
rc API here requires **POST for every call** including `job/status` and `core/stats` — GET with
|
||||
query params 404s.
|
||||
- **Logs / "past runs":** per-run logs in `/var/log/rclone-backup/<safe>-<ts>.log`; one-line
|
||||
JSON summary per run appended to `/var/log/rclone-backup/runs.jsonl`.
|
||||
- **Failure notify:** `OnFailure=rclone-backup-notify@%n.service` → logs to journal today;
|
||||
**TODO** wire to Hermes `send_message` (Matrix) per [backups](../infrastructure/backups.md).
|
||||
|
||||
## rclone + Proton Drive
|
||||
|
||||
- **rclone** installed from the official binary (not apt) so the `protondrive` backend is present
|
||||
(`rclone v1.74.3`).
|
||||
- Remote **`proton:`** (type `protondrive`). Config at `/root/.config/rclone/rclone.conf`, mode 600.
|
||||
**This file is a secret** (holds the obscured Proton password + TOTP secret + session) — **never
|
||||
commit it.** Escrow the Proton account creds in the password manager.
|
||||
- **Config gotchas** (from rclone docs/forum):
|
||||
- Log into Proton via a **browser at least once** first, or key generation fails.
|
||||
- For unattended runs, store the **TOTP _secret_** (not a 6-digit code) so rclone self-generates
|
||||
codes; obscure with `rclone obscure`.
|
||||
- Passwords with **extended-ASCII** characters are known to break auth.
|
||||
- Proton's API is rate-limited → keep `--transfers`/`--tpslimit` conservative (baked into the runner).
|
||||
- **DR escrow (pending):** store the Proton creds as sops secret `secrets/protondrive.yaml`, granted
|
||||
to this LXC's age key, so the remote can be rebuilt after a re-provision.
|
||||
|
||||
## The UI (rclone Web GUI)
|
||||
|
||||
`rclone rcd --rc-web-gui --rc-no-auth --rc-addr 0.0.0.0:5572` (assets auto-downloaded on first
|
||||
start). Reach it at **http://192.168.8.214:5572** on the LAN.
|
||||
|
||||
> **Security note:** `--rc-no-auth` exposes *full* rclone control — including deleting remote data —
|
||||
> to anyone on the LAN (accepted per the design choice). The container has only a LAN NIC, so it is
|
||||
> not publicly reachable. Harden later by adding `--rc-user/--rc-pass` or fronting it with Authentik.
|
||||
|
||||
## Tracked config (deferred)
|
||||
|
||||
**Not yet tracked.** The runner, systemd units, and `folders.list` currently live as plain files
|
||||
directly on the LXC — fully functional, just not version-controlled or auto-deployed. A
|
||||
`dtoro/rclone` gitea repo package (runner, units, `install.sh`, webhook receiver) is pre-built and
|
||||
staged at `/root/rclone-repo` on the LXC for whenever this gets tracked (Shape A, like
|
||||
[caddy](121-caddy.md)). Gitea `ALLOWED_HOST_LIST` already includes `192.168.8.214` in anticipation.
|
||||
See [auto-deploy](../infrastructure/auto-deploy.md).
|
||||
|
||||
**Selected folders (live in `/etc/rclone-backup/folders.list`):** `/mnt/library/cloud` (287G),
|
||||
`/mnt/library/documents` (249M), `/mnt/library/repos` (83M). `/mnt/library/notes` was synced once as
|
||||
a connectivity test (not in the recurring set). Proton quota checked: 2 TiB plan, ~1.65 TiB free
|
||||
after this set.
|
||||
|
||||
## Enrollment gotcha: `pct exec` PATH
|
||||
|
||||
`pct exec` (lxc-attach) does **not** source `/etc/environment` or run a login shell, so
|
||||
`/usr/local/bin` (where bootstrap installs `sops`) isn't on `$PATH` by default — bootstrap's own
|
||||
`command -v sops` post-install check failed under `pct exec` even though the binary installed fine.
|
||||
Fixed by symlinking `/usr/local/bin/{sops,homelab}` into `/usr/bin` (always on the minimal PATH),
|
||||
rather than relying on `/etc/environment`. Same category as the documented [`pct exec` no-initgroups
|
||||
gotcha](../infrastructure/media-permissions.md#gotchas) — worth adding to
|
||||
[agent-enrollment.md troubleshooting](../../../.agents/operations/agent-enrollment.md#troubleshooting) if it recurs
|
||||
on future LXC bootstraps.
|
||||
|
||||
## Known issue: `rclone-rcd.service` OOM-killed under 1 GiB RAM (root cause, resolved)
|
||||
|
||||
What looked like repeated "protondrive silently stalls" was actually **`rclone-rcd.service` (the rc
|
||||
API daemon backing the Web GUI and, since the rc-API redesign, all actual sync work) getting
|
||||
OOM-killed** under the original 1 GiB RAM allocation — `journalctl` confirms
|
||||
`A process of this unit has been killed by the OOM killer` at the exact moment a transfer had
|
||||
"frozen." systemd's own `Restart=on-failure` (5s) auto-respawns it, but every in-flight job's state
|
||||
is lost on the kill, which looked identical to a silent backend hang from the outside (frozen
|
||||
`core/stats`, no new log lines). **Fix: bumped the LXC's memory to 2 GiB** (`pct set 132 -memory
|
||||
2048` — applies live via the host cgroup, confirmed via `cat /sys/fs/cgroup/lxc/132/memory.max` on
|
||||
hubris, no container reboot needed). After the bump, the full folder set (`cloud` 287G, `documents`
|
||||
249M, `repos` 83M) completed cleanly with no further kills.
|
||||
|
||||
**`rclone-backup-watchdog.timer`** (every 5 min) → `rclone-backup-watchdog.sh`: if
|
||||
`rclone-backup.service` is active but total transferred bytes (global `core/stats` on the rc API)
|
||||
haven't moved for 15 minutes, it restarts both `rclone-rcd.service` (clears any stuck/orphaned job —
|
||||
this is the actual daemon holding the work, not the thin wrapper) and then `rclone-backup.service`
|
||||
(`--no-block`, load-bearing — see below). Kept as a safety net even after the RAM fix, in case
|
||||
memory pressure returns under a larger folder set later. State kept in
|
||||
`/var/lib/rclone-backup/watchdog-state.json`, cleared whenever the service isn't running.
|
||||
|
||||
**Two watchdog design bugs found and fixed while chasing this (2026-07-03):**
|
||||
1. **Wrong stats-group key.** Per-job progress polling queried `core/stats` under `job/<jobid>`,
|
||||
but rclone tracks stats under whatever `_group` name the job was submitted with. Made a perfectly
|
||||
healthy sync look stalled at 0 bytes for 22+ hours in its own log. Fixed by using the same
|
||||
`group` variable consistently. **Lesson: distrust the per-run log's "progress bytes=" line during
|
||||
an incident; cross-check with unfiltered `core/stats` first.**
|
||||
2. **Watchdog restarted only the thin wrapper, and blocked doing it.** The actual `rclone sync` work
|
||||
runs inside `rclone-rcd.service`, not `rclone-backup.service` — restarting the wrapper alone left
|
||||
any stuck job orphaned inside `rcd` while a new wrapper submitted a duplicate job on top. Worse,
|
||||
`systemctl restart rclone-backup.service` (no `--no-block`) blocks until the *new* invocation's
|
||||
long-running `ExecStart` exits — which could be hours — so the watchdog's own oneshot service
|
||||
never logged "Finished," and `OnUnitActiveSec` (which schedules relative to the previous run
|
||||
*finishing*) never fired again. The watchdog silently disabled itself after exactly one use.
|
||||
Fixed: restart `rclone-rcd.service` first, then `rclone-backup.service` with `--no-block`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Backups](../infrastructure/backups.md) — this job supersedes the disabled restic-on-USB backup
|
||||
- [Hubris host](../hosts/hubris.md) — owns `/mnt/library`
|
||||
- [Media permissions](../infrastructure/media-permissions.md) — read-only consumer of `/mnt/library`
|
||||
- [Containers index](index.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-03 — root cause found (OOM, not Proton); RAM bumped to 2 GiB; full folder set completed
|
||||
|
||||
What looked like repeated silent "protondrive stalls" turned out to be **`rclone-rcd.service`
|
||||
getting OOM-killed** under the original 1 GiB RAM allocation — confirmed via
|
||||
`journalctl -u rclone-rcd.service` showing `killed by the OOM killer` at the exact freeze point.
|
||||
Bumped the LXC to 2 GiB RAM (live, no reboot). After the bump: `cloud` (287G) completed cleanly
|
||||
(exit 0), `documents` (249M) completed with 1 minor error (259.7 MB transferred), `repos` (83M)
|
||||
completing as of this entry. Also fixed two real watchdog bugs found while chasing this (wrong
|
||||
stats-group key making a healthy sync look frozen; watchdog restarting only the wrapper with a
|
||||
blocking `systemctl restart`, causing it to silently disable itself after one use) — see "Known
|
||||
issue" above for full detail. The watchdog is kept as a safety net going forward even though the RAM
|
||||
bump addresses the actual root cause.
|
||||
|
||||
### 2026-07-02 — runner rewritten to submit jobs via the rc API (GUI job visibility)
|
||||
|
||||
The original runner (`rclone sync` invoked as a standalone CLI subprocess) was invisible to the Web
|
||||
GUI's Jobs panel — the GUI only tracks work submitted through its own `rcd` process. Rewrote
|
||||
`/usr/local/sbin/rclone-backup.sh` in Python, submitting each folder via `POST /sync/sync` with
|
||||
`_async: true` against `http://127.0.0.1:5572` (the running `rclone-rcd.service`), then polling
|
||||
`POST /job/status` + `POST /core/stats` (both **must be POST** — GET-with-querystring 404s on this
|
||||
rc API) until finished, logging periodic progress snapshots and the same `runs.jsonl` summary line
|
||||
as before. Verified live: submitted job visible in `POST /job/list`'s `runningIds` while running,
|
||||
completed cleanly (`success: true`) once done. Deployed via atomic rename (write-then-`mv`) rather
|
||||
than truncating in place, specifically so it wouldn't risk corrupting the still-running original
|
||||
`cloud`+`documents`+`repos` sync mid-flight (verified after the fact: that sync's bash process was
|
||||
unaffected, kept running to completion under the old in-memory script content). The already-running
|
||||
scheduled sync from before this change is a standalone process and won't retroactively appear in the
|
||||
GUI; every run after this point will.
|
||||
|
||||
### 2026-07-02 — Proton Drive auth fixed; real folder set enabled; first live sync
|
||||
|
||||
Initial `rclone config` failed 2FA (`422 ... auth/v4/2fa`) because a live 6-digit TOTP code was
|
||||
entered instead of the TOTP secret — reconfigured with the secret, auth now works
|
||||
(`rclone lsd proton:` lists the Drive). Verified end-to-end with a real sync of `/mnt/library/notes`
|
||||
(219 objects, 5.964 MiB, exit 0) — confirmed files land as plain, browsable objects on Proton (not
|
||||
an opaque archive), matching the plain-mirror + Proton-E2E design. Checked Proton quota (2 TiB
|
||||
plan, 1.945 TiB free) before enabling a large folder. `folders.list` set to the real selection:
|
||||
`cloud` (287G), `documents` (249M), `repos` (83M); a full sync of that set was kicked off via the
|
||||
actual `rclone-backup.service` unit (not an ad-hoc call) to validate the real monthly path early
|
||||
rather than waiting for the Aug 1 timer. Tracked-repo step (`dtoro/rclone` on gitea) deferred by
|
||||
choice — runner/units/`folders.list` remain plain files on the LXC for now; the repo package stays
|
||||
staged at `/root/rclone-repo` for later.
|
||||
|
||||
### 2026-07-01 — provisioned; enrolled
|
||||
|
||||
LXC 132 created (Debian 13, privileged, `192.168.8.214`, `/mnt/library` read-only). rclone v1.74.3
|
||||
installed from the official binary (`protondrive` backend present). Runner + monthly timer +
|
||||
`folders.list` deployed; rclone Web GUI (`rcd`, LAN-only no-auth) live on `:5572`. Enrolled into
|
||||
homelab-context (`--no-mesh`, LAN-only issuance): age key issued, inventory finalized, shared
|
||||
secrets granted, `homelab whoami` + `homelab secret hello` verified. Gitea `ALLOWED_HOST_LIST`
|
||||
updated to include `192.168.8.214`. Hit and fixed a `pct exec` PATH gotcha (see below). Proton Drive
|
||||
remote, `dtoro/rclone` tracked repo + webhook, and the `secrets/protondrive.yaml` escrow remain
|
||||
operator-run follow-ups (credentialed steps — Proton password/2FA, repo creation). Restic-on-USB
|
||||
backup deprecated in the same change.
|
||||
@@ -1,61 +0,0 @@
|
||||
# seanime (LXC 133) — Seanime anime media server
|
||||
|
||||
## Summary
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| VMID | 133 |
|
||||
| Host | strong |
|
||||
| Role | Anime media server (online streaming + torrent client) |
|
||||
| LAN IP | 192.168.8.248/28 (vmbr1) |
|
||||
| Public URL | https://seanime.hubris.network |
|
||||
| Backend | Seanime v3.9.0, systemd service |
|
||||
| Port | 43211 |
|
||||
|
||||
## Mounts
|
||||
|
||||
- `/anime` → `/mnt/media_local/anime` (ludo-lvm, bind mount) — existing anime collection
|
||||
|
||||
## Services
|
||||
|
||||
### Seanime (native binary)
|
||||
|
||||
- Binary: `/opt/seanime/bin/seanime`
|
||||
- Data: `/opt/seanime/data/`
|
||||
- Config: `/opt/seanime/data/config.toml`
|
||||
- Service: `systemctl status seanime`
|
||||
- Flags: `--host 0.0.0.0 --port 43211 --datadir /opt/seanime/data --disable-password`
|
||||
- Config highlights:
|
||||
- `secureMode = 'lax'` (allows non-local Caddy proxy)
|
||||
- `trustedProxies = ['192.168.8.0/24']`
|
||||
|
||||
### Torrent client (qBittorrent on arriman)
|
||||
|
||||
- Host: `192.168.8.245:8080`
|
||||
- Auth: subnet whitelist (no password needed from homelab LAN)
|
||||
- qBittorrent config updated: `AuthSubnetWhitelist=192.168.8.0/24`
|
||||
|
||||
### Extensions installed
|
||||
|
||||
**Online streaming (8):**
|
||||
HiAnime, AniWatch, KickAssAnime, Anicrush, Animo, AniNeko, Senshi, Sudatchi
|
||||
|
||||
## Caddy
|
||||
|
||||
- Config: `seanime.hubris.network` block in `/etc/caddy/Caddyfile` (dtoro/caddy-conf repo)
|
||||
- TLS: Let's Encrypt DNS-01 via IONOS API
|
||||
- Backend: `192.168.8.248:43211`
|
||||
|
||||
## DNS
|
||||
|
||||
- `seanime.hubris.network` A record → `192.168.8.175` (Caddy)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-05 — initial creation
|
||||
- LXC 133 created on strong (Debian 13, 2 cores / 2 GiB)
|
||||
- Seanime v3.9.0 installed as native binary + systemd service
|
||||
- /anime bind mount from ludo-lvm
|
||||
- qBittorrent on arriman configured as torrent client
|
||||
- Caddy reverse proxy + DNS records set up
|
||||
- Online streaming extensions installed
|
||||
@@ -1,82 +0,0 @@
|
||||
# 134 — `romm`
|
||||
|
||||
Self-hosted ROM manager ([RomM](https://romm.app)). Browse, search, and play
|
||||
your retro game library from the browser. Runs on Docker Compose with a
|
||||
MariaDB sidecar.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Hostname:** `romm`
|
||||
- **IP:** `192.168.8.249/28` (static, vmbr1 on strong)
|
||||
- **Privilege:** privileged
|
||||
- **Resources:** 1 core / 2 GiB RAM / 16 GiB rootfs (Debian 13, ludo-lvm)
|
||||
- **Mounts:** `/mnt/media_local` → `/mnt/library`
|
||||
- **Public hostname:** `roms.hubris.network`
|
||||
|
||||
## Service / port map
|
||||
|
||||
| Service | Listen | Notes |
|
||||
|---------|--------|-------|
|
||||
| RomM | `192.168.8.249:80` | HTTP (Caddy terminates TLS) |
|
||||
| MariaDB | internal only | Sidecar in the same compose stack |
|
||||
|
||||
## Compose
|
||||
|
||||
Located at `/opt/romm/docker-compose.yml`. Key points:
|
||||
|
||||
- Image: `rommapp/romm:latest`
|
||||
- DB sidecar: `mariadb:latest` with healthcheck
|
||||
- ROM library: `/mnt/library/roms` → `/romm/library` (writable)
|
||||
- Resources (covers, etc.): Docker named volume `romm_resources` → `/romm/resources`
|
||||
- Saves/states: `/opt/romm/assets` → `/romm/assets`
|
||||
- Config: `/opt/romm/config` → `/romm/config`
|
||||
- Auth key: auto-generated, stored in `/opt/romm/.env`
|
||||
|
||||
Environment (`/opt/romm/.env`):
|
||||
- `DB_ROOT_PASSWD` / `DB_PASSWD` — MariaDB credentials
|
||||
- `DB_USER=romm-user` / `DB_NAME=romm`
|
||||
- `ROMM_AUTH_SECRET_KEY` — auto-generated
|
||||
|
||||
## ROM library structure
|
||||
|
||||
RomM expects `/mnt/library/roms/<platform>/<game>/<rom>`. Create platform
|
||||
directories as needed:
|
||||
|
||||
```
|
||||
/mnt/media_local/roms/
|
||||
├── gba/
|
||||
│ └── Pokemon - Emerald/
|
||||
│ └── Pokemon Emerald.gba
|
||||
├── snes/
|
||||
│ └── Super Mario World/
|
||||
│ └── Super Mario World.sfc
|
||||
└── psx/
|
||||
└── Final Fantasy VII/
|
||||
└── Final Fantasy VII.bin
|
||||
```
|
||||
|
||||
## Media permissions
|
||||
|
||||
The `/mnt/media_local/roms` directory is owned `root:media` with mode `2775`
|
||||
(setgid). New files inherit the `media` GID (10000). The LXC is privileged so
|
||||
no idmap block is needed — in-container UID/GID matches the host. Docker
|
||||
containers within the LXC run as-is (read-only mount).
|
||||
|
||||
## Related
|
||||
|
||||
- [Strong host](../hosts/strong.md)
|
||||
- [Caddy (121)](121-caddy.md) — `roms.hubris.network → 192.168.8.249:80`
|
||||
- [DNS (107)](107-dns.md) — `roms.hubris.network A 192.168.8.175`
|
||||
- [Media permissions](../infrastructure/media-permissions.md)
|
||||
- [RomM docs](https://docs.romm.app)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-05 — provisioned
|
||||
|
||||
LXC 134 created on strong (Debian 13, privileged, `192.168.8.249/28`).
|
||||
Docker + Compose installed. RomM stack deployed at `/opt/romm/`.
|
||||
Created `/mnt/media_local/roms` with setgid `media:GID=10000` mode `2775`.
|
||||
Caddy `roms.hubris.network` → `192.168.8.249:80`.
|
||||
DNS `roms A 192.168.8.175` added to Technitium.
|
||||
Hubris /32 route for `.249` added to `50-strong-route`.
|
||||
@@ -1,57 +0,0 @@
|
||||
# LXC containers — index
|
||||
|
||||
Most containers live on [`hubris`](../hosts/hubris.md). Some have been
|
||||
[migrated to `strong`](../hosts/strong.md) (Phase 1+2, 2026-07-05).
|
||||
|
||||
| ID | Name | Host | IP | Priv | Cores | RAM | Disk | Mounts | Public hostname | Status |
|
||||
| --- | ---------------- | ------- | --------------- | ---- | ----- | ----- | ----- | --------------------- | ------------------------------------- | -------- |
|
||||
| 101 | [jellyfin](101-jellyfin.md) | **strong** | 192.168.8.246 | priv | 4 | 8 GiB | 16 GiB | `/mnt/media_local` (via mp0) | `media.hubris.network` | running |
|
||||
| 103 | [paperless](103-paperless.md) | hubris | 192.168.8.130 | priv | 2 | 3 GiB | 8 GiB | `/mnt/library` | `paperless.hubris.network` | running |
|
||||
| 104 | [gitea](104-gitea.md) | hubris | 192.168.8.121 | priv | 1 | 1 GiB | 8 GiB | `/mnt/library` | `git.hubris.network` | running |
|
||||
| 105 | [apps](105-apps.md) | hubris | 192.168.8.205 | priv | 2 | 4 GiB | 30 GiB | `/mnt/library` | `docker` / `artifacto` / `blog` | running |
|
||||
| 114 | [nextcloud](114-nextcloud.md) | hubris | 192.168.8.224 | priv | 4 | 6 GiB | 25 GiB | `/mnt/library` | `cloud.hubris.network` | running |
|
||||
| 118 | [elementsynapse](118-elementsynapse.md) | **strong** | 192.168.8.242 | unpriv | 2 | 4 GiB | 32 GiB | — | `matrix.hubris.network` | running |
|
||||
| 119 | [sophia](119-sophia.md) | hubris | 192.168.8.157 | priv | 2 | 1 GiB | 10 GiB | `/mnt/library` | — | running |
|
||||
| 120 | [mule-images](120-mule-images.md) | hubris | 192.168.8.136 | priv | 6 | 12 GiB | 60 GiB | `/mnt/library` + `/dev/dri` (iGPU) | `photos.hubris.network` | running |
|
||||
| 121 | [caddy](121-caddy.md) | hubris | 192.168.8.175 | unpriv | 1 | 512 MiB | 6 GiB | — | (terminates all `*.hubris.network`) | running |
|
||||
| 122 | [arriman](122-arriman.md) | **strong** | 192.168.8.245 | priv | 4 | 8 GiB | 24 GiB | `/mnt/media_local` (via mp0) | `jellyseerr` / `qbit` / `sab` | running |
|
||||
| 124 | [authentik](106-auth-outpost.md) | hubris | 192.168.8.180 | priv | 2 | 4 GiB | 20 GiB | — | `auth.hubris.network` | running |
|
||||
| 128 | [trmnl](128-trmnl.md) | hubris | 192.168.8.211 | unpriv | 1 | 768 MiB | 8 GiB | — | `trmnl.hubris.network` | running |
|
||||
| 129 | [house](129-house.md) | **strong** | 192.168.8.244 | unpriv | 2 | 3 GiB | 8 GiB | — | `house.hubris.network` | running |
|
||||
| 130 | [grimmory](130-grimmory.md) | **strong** | 192.168.8.247 | priv | 1 | 2 GiB | 16 GiB | `/mnt/media_local` (via mp0) | `books.hubris.network` | running |
|
||||
| 131 | [teddycloud](131-teddycloud.md) | hubris | 192.168.8.150 | — | 1 | 1 GiB | 16 GiB | `/mnt/library` | `teddy.hubris.network` (no auth gate) | running |
|
||||
|| 132 | [rclone](132-rclone.md) | hubris | 192.168.8.214 | priv | 1 | 1 GiB | 8 GiB | `/mnt/library` (**ro**) | — (LAN-only UI `:5572`) | running |
|
||||
|| 134 | [romm](134-romm.md) | **strong** | 192.168.8.249 | priv | 1 | 2 GiB | 16 GiB | `/mnt/media_local` (via mp0) | `roms.hubris.network` | running |
|
||||
|
||||
## Recently destroyed (kept for archaeology)
|
||||
|
||||
| ID | Name | Destroyed | Reason |
|
||||
| --- | ---------------- | --------------- | --------------------------------------------- |
|
||||
| 127 | mule-photos-new | 2026-05-22 | PhotoPrism + sidecar + SvelteKit stack promoted to LXC 120 via Mulimage 2.0 merge (`70dc1b6`); M0 test LXC retired. Caddy + dnsmasq + gitea webhook + NC webhook listeners all cleaned up in the same cutover. |
|
||||
| 100 | arr (yunohost) | ~2026-04-28 | Migrated to docker stack on [arriman](122-arriman.md); planned retention window expired |
|
||||
| 106 | flaresolverr | ~2026-04-28 | Folded into the arriman docker compose |
|
||||
| 116 | heaper | 2026-05-14 | Decommissioned by user; data subtree at `/mnt/library/heaper` (224 MiB) retained |
|
||||
| 126 | plato | 2026-06-28 | Notes/discovery workspace decommissioned; data at `/mnt/library/documents/plato` retained for archaeology |
|
||||
| 123 | claudio-bot (destroyed — see [archive](archive/123-claudio-bot.md)) | 2026-06-04 | Replaced by Hermes Agent on mac-mini; monitoring migrated to `homelab-health-watchdog` cron. See [deprecation plan](../../../plans/done/2026-06-04_130000-deprecate-claudio-bot.md) |
|
||||
| 109 | syncthing | 2026-05-14 | Decommissioned by user; `/mnt/library/syncthing` was already empty |
|
||||
| 125 | seafile | 2026-05-13 | Seafile Pro evaluation, user disliked the product; teardown also removed `files.hubris.network` from caddy + dnsmasq |
|
||||
| 107 | marimo | between 2026-04-21 and 2026-04-28 | Decommissioned |
|
||||
| 110 | photoprism | between 2026-04-21 and 2026-04-28 | Replaced by [mulita](120-mule-images.md) |
|
||||
| 111 | karakeep | between 2026-04-21 and 2026-04-28 | Decommissioned |
|
||||
| 112 | immich | between 2026-04-21 and 2026-04-28 | Replaced by [mulita](120-mule-images.md) |
|
||||
| 115 | reticulum | between 2026-04-21 and 2026-04-28 | Decommissioned |
|
||||
|
||||
> Several `.conf.bak` files survive under `/etc/pve/lxc/` if you need to recover any of the configs.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All net0 are `bridge=vmbr0`, `ip=dhcp` except [124 (authentik)](106-auth-outpost.md) which is statically `192.168.8.180/24`. Containers on [strong](../hosts/strong.md) use `bridge=vmbr1` with static IPs in the `192.168.8.240/28` range.
|
||||
- `onboot=1` on every container — the host brings them up after `pve-guests.service`.
|
||||
- Bind mounts are declared as `mp0: /mnt/library,mp=/mnt/library` on hubris, or `mp0: /mnt/media_local,mp=/mnt/library` on strong.
|
||||
- Most containers are privileged. Unprivileged ones require an idmap block in their conf to participate in the [media GID 10000](../infrastructure/media-permissions.md) standard.
|
||||
|
||||
## Related
|
||||
- [Hubris host](../hosts/hubris.md)
|
||||
- [Media permissions](../infrastructure/media-permissions.md)
|
||||
- [Caddy](121-caddy.md) — terminates every public hostname
|
||||
- [DNS](../infrastructure/dns.md) — split-horizon entries for each subdomain
|
||||
@@ -1,9 +0,0 @@
|
||||
# Hosts
|
||||
|
||||
The two Proxmox VE nodes that run the fleet. Narrative pages; the machine-readable host records are
|
||||
the generated `hosts/*.yaml` files at the repo root.
|
||||
|
||||
| Document | What it covers |
|
||||
|----------|----------------|
|
||||
| [hubris.md](hubris.md) | Primary PVE node (GMKtec NucBox M6 Ultra, `192.168.8.77`) — runs most LXCs plus the VMs. |
|
||||
| [strong.md](strong.md) | Secondary PVE node / cluster member (`192.168.178.181`) — hosts the LXCs migrated from hubris. |
|
||||
@@ -1,159 +0,0 @@
|
||||
# `strong` — Proxmox host
|
||||
|
||||
Second node in the `Homelab` cluster, alongside [hubris](hubris.md). Formerly
|
||||
a Linux dev workstation nicknamed "ludo" (or "ludo-mini") — reformatted to
|
||||
Proxmox VE on 2026-07-01. No LXCs/VMs deployed on it yet.
|
||||
|
||||
## At a glance
|
||||
|
||||
- **Role:** Proxmox VE 9.2.3 cluster member (kernel `7.0.12-1-pve`) — hosts [7 LXCs](../containers/index.md) migrated from hubris (Phase 1+2, 2026-07-05)
|
||||
- **Naming:** the OS/cluster hostname is `strong` (leftover from install,
|
||||
kept as-is). This wiki page and `inventory.yaml` also use `strong` as of
|
||||
2026-07-01 — earlier the same day the inventory entry was briefly named
|
||||
`ludo-mini`, but that was renamed so the hostname bootstrap needs
|
||||
(`hosts/$(hostname).yaml`) would just resolve. "Ludo"/"ludo-mini" remains
|
||||
the machine's everyday nickname; some older docs (investigations, the
|
||||
library-SSD migration plan) still refer to it that way — that's fine,
|
||||
those are historical.
|
||||
- **Hardware:** AMD Ryzen 7 PRO 6850U, 16 threads, 28 GiB RAM.
|
||||
- `nvme0n1` — MasonSemi MC3100 1TB (boot/OS): `pve-root` 96G, `pve-data`
|
||||
(thinpool) 815G, 8G swap.
|
||||
- `nvme1n1` — WD_BLACK SN7100 2TB, added 2026-07-01. Arrived with an
|
||||
existing APFS partition (previously used in a Mac) — wiped via
|
||||
`pvesh set nodes/strong/disks/wipedisk --disk /dev/nvme1n1`, then turned
|
||||
into its own LVM-thin pool via `pvesh create nodes/strong/disks/lvmthin`
|
||||
(handles pvcreate/vgcreate/lvcreate + storage.cfg registration in one
|
||||
step). Registered as Proxmox storage `ludo-lvm` (VG `ludo-lvm`, thinpool
|
||||
`ludo-lvm`, ~1.8 TiB usable — kept the `ludo-lvm` name since that's what
|
||||
was actually created live; not worth renaming), restricted `nodes strong`
|
||||
in `/etc/pve/storage.cfg` — same pattern as hubris's `library` pool.
|
||||
Empty so far; this is separate from the [library-SSD migration
|
||||
plan](../../../.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)'s
|
||||
planned drive move from hubris (that hasn't happened) — this is
|
||||
general-purpose VM/CT capacity.
|
||||
- **Network:** `vmbr0` is bridged straight onto the household LAN —
|
||||
`192.168.178.181/24`, gateway `192.168.178.1`. Unlike hubris, there is no
|
||||
internal/uplink bridge split yet — `vmbr0` here plays the role hubris's
|
||||
`vmbr1` plays there. Reachable from the homelab subnet (`192.168.8.0/24`)
|
||||
via the existing Fritz!Box static route through hubris; no new cabling
|
||||
or routing was needed to join the cluster.
|
||||
- **Storage:** `local` + `local-lvm` (cluster-wide default names, shared
|
||||
with hubris by convention, actually separate per-node volumes). Hubris's
|
||||
`library` lvmthin pool is restricted to `nodes hubris` in
|
||||
`/etc/pve/storage.cfg` and does not exist here.
|
||||
- **Mesh:** Netbird not installed — fresh OS wiped whatever the old
|
||||
workstation had. Reachable today only via LAN routing (confirmed DNS for
|
||||
`*.hubris.network` already resolves correctly here via `192.168.8.2`).
|
||||
Add to Netbird if off-LAN access to this host itself (distinct from any
|
||||
future guests) is needed.
|
||||
- **Homelab-context client enrollment:** done 2026-07-01 via
|
||||
`bootstrap.sh --no-secrets` (reused the operator's existing Gitea PAT for
|
||||
the initial clone). `/opt/homelab-context`, the `homelab` CLI, and the
|
||||
5-min sync timer are live; `homelab whoami` resolves correctly. See
|
||||
[agent-enrollment.md](../../../.agents/operations/agent-enrollment.md).
|
||||
- **Age key / secrets:** issued the same day over plain LAN (no Netbird
|
||||
needed — see the `--no-mesh` bootstrap.sh fix below). Key lives at
|
||||
`/etc/age/key.txt`; pubkey `age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4`
|
||||
recorded in `inventory.yaml`. Not yet a recipient on any actual secret
|
||||
(`hello.yaml`, `gitea-pat.yaml`, etc.) — that's a separate grant, see
|
||||
["Granting a secret to a new client"](../../../.agents/operations/agent-enrollment.md#granting-a-secret-to-a-new-client).
|
||||
|
||||
## Cluster membership
|
||||
|
||||
Joined hubris's single-node cluster (`Homelab`) via `pvecm add` on
|
||||
2026-07-01. See [hosts/hubris.md#cluster](hubris.md#cluster) for the full
|
||||
cluster picture, node IDs, and the quorum tradeoff (2 nodes, no QDevice —
|
||||
either node going down freezes management on the survivor).
|
||||
|
||||
## SSH
|
||||
|
||||
Root login via the same key set trusted on hubris (`root@hubris`,
|
||||
`d.toro.v@pm.me`) — installed 2026-07-01 by appending to
|
||||
`/root/.ssh/authorized_keys` (now symlinked to `/etc/pve/priv/authorized_keys`
|
||||
post cluster-join, so it's cluster-synced same as hubris). No password auth
|
||||
needed going forward.
|
||||
|
||||
## Related
|
||||
|
||||
- [hubris — Proxmox host](hubris.md)
|
||||
- [Library SSD migration plan](../../../.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md) — the larger project this is Phase 1 of (filename kept as-is, historical)
|
||||
- [Network](../infrastructure/network.md)
|
||||
- [SSH access](../infrastructure/ssh-access.md)
|
||||
- [Agent enrollment](../../../.agents/operations/agent-enrollment.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-05 — Phase 2d: grimmory (130) migrated to strong
|
||||
Migrated book library from hubris (192.168.8.213) to strong (192.168.8.247).
|
||||
Rsync'd /books (2.6G) from hubris library SSD to ludo-lvm/media. Container
|
||||
mounts /mnt/media_local → /mnt/library locally. Caddy backend for
|
||||
books.hubris.network updated.
|
||||
|
||||
### 2026-07-05 — Phase 2: arriman (122) + jellyfin (101) migrated; library on ludo-lvm
|
||||
Migrated arriman (→ 192.168.8.245) and jellyfin (→ 192.168.8.246) to strong.
|
||||
Created 1.5T thin volume on ludo-lvm for media library (/mnt/media_local).
|
||||
Rsync'd 363G of media data (movies, tv, anime, downloads, music) from hubris
|
||||
library SSD to ludo-lvm. Both containers now mount /mnt/media_local directly
|
||||
as local ext4 — no NFS cross-node dependency. Jellyfin gets Radeon 680M iGPU
|
||||
(for VAAPI) + RX 7600 dGPU passthrough via dev0/dev1. Caddy backends updated
|
||||
for media, jellyseerr, qbit, sab. Hubris freed 16 GiB RAM (8+8).
|
||||
Dhcp scope narrowed to 192.168.8.100-239.
|
||||
|
||||
### 2026-07-05 — house (LXC 129) migrated to strong; DHCP scope narrowed
|
||||
Migrated Yuvomi family planner from hubris (192.168.8.212) to strong
|
||||
(192.168.8.244). Followed same restore pattern as elementsynapse (Phase 1b).
|
||||
Discovered IP conflict: teddycloud (LXC 131) had 192.168.8.243 via DHCP
|
||||
(scope was 192.168.8.241-254). Narrowed DHCP scope to 192.168.8.100-239,
|
||||
gave teddycloud static IP 192.168.8.150. Caddy updated: house.hubris.network
|
||||
→ 192.168.8.244:3000, teddy.hubris.network → 192.168.8.150:8443.
|
||||
|
||||
### 2026-07-05 — elementsynapse (LXC 118) migrated to strong
|
||||
Migrated Matrix/Synapse + Element client from hubris (192.168.8.239) to a new
|
||||
LXC on strong (192.168.8.242). Added vmbr1 on strong at 192.168.8.241/28
|
||||
(portless internal bridge) for strong-hosted guests. Enabled IP forwarding,
|
||||
proxy ARP on hubris vmbr0, and specific /32 routes for strong's guest subnet.
|
||||
Caddy updated: element.hubris.network → 192.168.8.242:8080.
|
||||
VPS traefik updated: matrix.hubris.network backend → 192.168.8.242:8008.
|
||||
This is Phase 1a of the strong migration plan — see .hermes/plans/2026-07-05_strong-migration-assessment.md.
|
||||
|
||||
### 2026-07-01 — age key issued over LAN; 3 bugs found/fixed in bootstrap.sh
|
||||
Re-ran bootstrap without `--no-secrets` to get a real age key. Hit three real bugs live, fixed all three in `bootstrap.sh` and re-ran clean:
|
||||
1. The `mcp`-CLI pipx-install step and the (unused, `--with-hermes`-only) Goose installer both called `sudo -u <user>` unconditionally — fails with "sudo: command not found" on a minimal root-only image with no `sudo` binary at all. Added a `run_as()` helper that only shells out to `sudo` when there's a real distinct invoking user.
|
||||
2. `sops` isn't an apt/dnf package (matches what `agent-enrollment.md`'s manual-install recipe already does) — the auto-installer tried `apt-get install sops` and failed outright. Added `install_sops_binary()`, fetching the GitHub release binary directly on both dnf and apt paths.
|
||||
3. Bigger one: running without `--no-secrets` unconditionally tries to install + interactively connect Netbird (device-code SSO), even though the very next check already knows how to accept plain LAN reachability instead. Over SSH with nobody watching, this hangs forever — had to manually kill a stuck `netbird up` process. Added `--no-mesh`, which skips the Netbird install/connect step but keeps the LAN-fallback path for secrets issuance. This run used `bootstrap.sh --no-mesh` and completed cleanly: `mesh: lan`, age key installed, `mcp` CLI installed via pipx (proving fix #1 too).
|
||||
|
||||
Result: age key at `/etc/age/key.txt`, pubkey recorded in `inventory.yaml`. Not yet granted access to any actual secret file — see the note above.
|
||||
|
||||
### 2026-07-01 — enrolled as a homelab-context client
|
||||
Ran `bootstrap.sh --no-secrets` (reused the operator's existing personal Gitea PAT for the initial clone rather than minting a fresh read-only one). Installed git, cloned `/opt/homelab-context`, installed the 5-min systemd sync timer, symlinked `homelab` CLI and `AGENTS.md`. Skipped age-key/secrets issuance and Netbird per operator choice — but bootstrap's own connectivity check reported `mesh: lan`, i.e. the secrets-issuance endpoint is already reachable over plain LAN, so re-running without `--no-secrets` later wouldn't require a Netbird join. Known gap: the `mcp` pipx CLI install step silently failed (`sudo: command not found` — bootstrap.sh's pipx step assumes a `sudo` binary even when already root; harmless, only affects the `homelab mcp <tool>` shell subcommand).
|
||||
|
||||
### 2026-07-01 — inventory identity renamed ludo-mini → strong
|
||||
Discovered while starting client enrollment: `bootstrap.sh` looks up
|
||||
`hosts/$(hostname).yaml`, and the OS hostname here is `strong`, not
|
||||
`ludo-mini`. Renaming the OS hostname was ruled out (already a cluster
|
||||
member — Proxmox doesn't support in-place node rename, only leave+rejoin).
|
||||
Renamed the wiki/inventory side instead: `inventory.yaml` key, this page
|
||||
(`hosts/ludo-mini.md` → `hosts/strong.md`), README, ssh-access.md all now
|
||||
say `strong`. "Ludo"/"ludo-mini" is still fine as a spoken nickname.
|
||||
|
||||
### 2026-07-01 — 2nd NVMe added; new LVM-thin pool `ludo-lvm`
|
||||
User added a WD_BLACK SN7100 2TB (`nvme1n1`), previously used in a Mac
|
||||
(arrived with an EFI + APFS partition table — confirmed disposable, wiped).
|
||||
Used Proxmox's own disk-management API rather than raw LVM commands:
|
||||
`pvesh set nodes/strong/disks/wipedisk --disk /dev/nvme1n1` to clear the old
|
||||
partition table/signatures, then `pvesh create nodes/strong/disks/lvmthin
|
||||
--name ludo-lvm --device /dev/disk/by-id/nvme-WD_BLACK_SN7100_2TB_251663803202
|
||||
--add_storage 1` to create the PV/VG/thinpool and register it as Proxmox
|
||||
storage in one step. Result: storage ID `ludo-lvm`, ~1.8 TiB, `content
|
||||
rootdir,images`, `nodes strong` (mirrors hubris's `library` node-restriction
|
||||
pattern — this pool only physically exists here). Empty — no VM/CT disks
|
||||
placed on it yet.
|
||||
|
||||
### 2026-07-01 — Proxmox install; joined Homelab cluster
|
||||
Reformatted from Linux workstation to Proxmox VE 9.2.3. SSH keys seeded from
|
||||
hubris's trusted set (root password used once, then discarded). Joined the
|
||||
existing `Homelab` cluster via `pvecm add 192.168.8.77 --use_ssh 1` from
|
||||
this node's side, using key-based SSH pre-authorized in both directions —
|
||||
no interactive password prompt needed for the join itself. Cluster now 2
|
||||
nodes (`hubris`, `strong`), quorate, no QDevice. Decided to leave hostname
|
||||
as `strong` and skip a QDevice for now — both revisitable later.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Knowledge
|
||||
|
||||
The durable, authoritative current-state documentation of the homelab: one page per node and per
|
||||
cross-cutting system, synthesized from live state and evidence. Structure and rules are in
|
||||
[the knowledge schema](../.agents/domains/knowledge/schema.md).
|
||||
|
||||
| Section | What it covers |
|
||||
|---------|----------------|
|
||||
| [wiki/hosts/](wiki/hosts/index.md) | Proxmox host narratives — `hubris`, `strong`. |
|
||||
| [wiki/containers/](wiki/containers/index.md) | LXC fleet — one page per container, plus the master table and archaeology. |
|
||||
| [wiki/vms/](wiki/vms/index.md) | Virtual machines — ZimaOS, Home Assistant OS. |
|
||||
| [wiki/infrastructure/](wiki/infrastructure/index.md) | Cross-cutting systems — DNS, ingress, mesh, storage, auth, monitoring, generated topology. |
|
||||
| [sources/](sources/index.md) | External reference docs and the pointer to incident evidence. |
|
||||
| [GLOSSARY.md](GLOSSARY.md) | Term definitions. |
|
||||
@@ -1,137 +0,0 @@
|
||||
# DNS — split-horizon `*.hubris.network`
|
||||
|
||||
LAN clients resolve `*.hubris.network` to the [Caddy reverse proxy](../containers/121-caddy.md) (`192.168.8.175`). Public clients resolve to the IONOS VPS (`82.165.190.79`) via an IONOS wildcard, where they hit the [VPS traefik public ingress](ingress.md).
|
||||
|
||||
There is **no wildcard on the LAN side**. Every subdomain needs an explicit entry.
|
||||
|
||||
## Components
|
||||
|
||||
- **Authoritative public DNS:** IONOS. `*.hubris.network → 82.165.190.79` (was `74.118.126.4` until 2026-04-22).
|
||||
- **LAN authoritative for `hubris.network` records:** [Technitium DNS](https://technitium.com) on [dns (107)](../containers/107-dns.md) at `192.168.8.2:53`. Syncs A records to the NetBird managed DNS zone via cron (see [dns-sync.py](../../../scripts/dns-sync.py)). Formerly dnsmasq on [authentik (124)](../containers/106-auth-outpost.md) (decommissioned 2026-06-04).
|
||||
- **PVE host** (`192.168.8.77`): resolver is the local Netbird daemon at `100.122.38.109:53`, which forwards to the LAN/upstream and learns hubris.network answers via that path. `netbird status` says "Nameservers: 0/0 Available" — confirming netbird does NOT manage a hubris.network zone; it just caches whatever the system resolver returns.
|
||||
- **Some LXCs** keep router DNS (`192.168.8.1`) or Tailscale MagicDNS (`100.100.100.100`), both of which return the public IONOS A record. Those LXCs need either a `/etc/hosts` override or local dnsmasq — see [mesh migration](mesh.md) for which technique applies where.
|
||||
|
||||
## Live entries (as of 2026-06-04)
|
||||
|
||||
```
|
||||
address=/auth.hubris.network/82.165.190.79 # → VPS, not Caddy (Authentik migrated 2026-05-31)
|
||||
address=/sso.hubris.network/192.168.8.175 # → Caddy → LAN forward-auth outpost (106); added 2026-06-01
|
||||
address=/git.hubris.network/192.168.8.175
|
||||
address=/media.hubris.network/192.168.8.175
|
||||
address=/paperless.hubris.network/192.168.8.175
|
||||
address=/books.hubris.network/192.168.8.175
|
||||
address=/home.hubris.network/192.168.8.175
|
||||
address=/cloud.hubris.network/192.168.8.175
|
||||
address=/matrix.hubris.network/192.168.8.175
|
||||
address=/proxmox.hubris.network/192.168.8.175
|
||||
address=/docker.hubris.network/192.168.8.175
|
||||
address=/jellyseerr.hubris.network/192.168.8.175
|
||||
address=/qbit.hubris.network/192.168.8.175
|
||||
address=/sab.hubris.network/192.168.8.175
|
||||
address=/blog.hubris.network/192.168.8.175
|
||||
address=/photos.hubris.network/192.168.8.175
|
||||
address=/photos-new.hubris.network/192.168.8.175
|
||||
address=/artifacto.hubris.network/192.168.8.175
|
||||
address=/zimaos.hubris.network/192.168.8.175
|
||||
address=/nfs-export.hubris.network/192.168.8.200
|
||||
```
|
||||
|
||||
Note: `nfs-export.hubris.network` is the only `.hubris.network` entry that points to a non-HTTP service (NFSv4 on port 2049). It bypasses [caddy (121)](../containers/121-caddy.md) because NFS is L4, not HTTP — Caddy has nothing to do.
|
||||
|
||||
## Why split-horizon
|
||||
|
||||
The IONOS wildcard points at the VPS for public ingress (per-host routers in [VPS traefik](ingress.md)). The VPS only routes hostnames it knows — anything else 404s. So LAN clients pointing at the public IP are a dead end for any service that isn't explicitly published. The [Technitium DNS](dns.md) override on `192.168.8.2` keeps LAN traffic on the home Caddy.
|
||||
|
||||
## The gotcha that cost a debug session (2026-04-22)
|
||||
|
||||
Creating a new Caddyfile site block is necessary but **not sufficient**. Without the Technitium entry on [dns (107)](../containers/107-dns.md), LAN queries fall through to upstream, get the public IONOS answer, and time out. Symptom: "subdomain doesn't load" even though Caddy config + cert are fine.
|
||||
|
||||
## Recipe — adding a new subdomain
|
||||
|
||||
1. Edit `/etc/caddy/Caddyfile` on [caddy (121)](../containers/121-caddy.md), commit + push to `dtoro/caddy-conf`. Webhook reloads caddy. See [auto-deploy](auto-deploy.md).
|
||||
2. Add the A record in the [Technitium UI](http://192.168.8.2) at `dns (107)` — the NetBird managed DNS zone sync picks it up within ~10 minutes via cron. Or add directly to the NetBird managed zone via API if you need it faster.
|
||||
3. Verify: `dig @192.168.8.2 +short <new>.hubris.network` → `192.168.8.175`.
|
||||
4. On macOS clients, flush: `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder`.
|
||||
|
||||
> The Technitium config on LXC 107 is the single source of truth. Never hand-edit the NetBird managed zone directly — the [`scripts/dns-sync.py`](../../../scripts/dns-sync.py) cron on 107 reconciles them and reaps stale records. See [dns.md changelog 2026-06-03](#2026-06-03--single-authoring-source-technitium--netbird-managed-zone-sync).
|
||||
|
||||
## Public path — what does and doesn't follow the LAN map
|
||||
|
||||
- Hostnames published in [VPS traefik dynamic config](ingress.md) (currently `artifacto.hubris.network`, `blog.hubris.network`) reach a real backend over the netbird mesh.
|
||||
- Anything else with a `*.hubris.network` URL hits the VPS but isn't routed anywhere — returns 404.
|
||||
- `netbird.hubris.network` is its own thing — TCP passthrough at the VPS, served by netbird-proxy. Doesn't follow the file-provider router pattern.
|
||||
|
||||
## Long-term plan
|
||||
|
||||
Either:
|
||||
- Move split-horizon DNS to the LAN router so `*.hubris.network → 192.168.8.175` is answered for every LAN client. Eliminates per-LXC overrides.
|
||||
- Or, once the [Tailscale → Netbird migration](mesh.md) completes, every LXC's resolver becomes the netbird daemon, which already learns hubris.network answers via the system resolver chain.
|
||||
|
||||
## Related
|
||||
- [Caddy (121)](../containers/121-caddy.md) — every LAN entry points here
|
||||
- [Ingress (VPS traefik)](ingress.md) — public-side counterpart
|
||||
- [Mesh migration](mesh.md) — per-LXC DNS workarounds during the transition
|
||||
- [DNS server (107)](../containers/107-dns.md) — Technitium, current DNS authority
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-28 — `plato.hubris.network` removed
|
||||
Plato (LXC 126) decommissioned. Technitium entry deleted; dns-sync cron reaped the NetBird managed zone record.
|
||||
|
||||
### 2026-06-17 — Fritz!Box DNSv4 server set to Technitium; old limitation resolved
|
||||
Household LAN clients (192.168.178.x) now resolve `*.hubris.network` to LAN IPs — the limitation noted below is resolved. Configured at Fritz!Box Internet → Filter → DNS Server → DNSv4 Server = `192.168.8.2` (User-defined).
|
||||
Authentik LXC 124 (192.168.8.180) destroyed — Authentik runs on VPS, DNS on Technitium (107).
|
||||
- Caddy: `auth.hubris.network`, `authentik` snippet, and `sso.hubris.network` all proxied to VPS
|
||||
- `header_up Host auth.hubris.network` added to strip `:443` from upstream Host header
|
||||
- All 9 LXCs' /etc/hosts updated: `auth.hubris.network → 192.168.8.175` (Caddy proxy)
|
||||
- Inventory: removed `hosts.authentik`, renamed `dnsmasq` service → `dns`
|
||||
- Docs: `124-authentik.md` deleted; dns.md references updated to Technitium (107)
|
||||
The "delete NetBird managed zone → forward everything to Technitium" plan was **abandoned** — NetBird's DNS defeats it: it **won't apply a nameserver group that contains the peer's own mesh IP** (the Mac's `100.122.234.17` → `Nameservers: 0/0 Available`), and nameserver-group forwarding to Technitium never actually took effect for mesh peers (the **managed zone was doing all the real work**; disabling it broke all mesh resolution). So the model is now:
|
||||
|
||||
- **Technitium (`192.168.8.2`) is the single place you author DNS** (UI/API, MX/SPF/CAA, full zone).
|
||||
- A **sync job on [dns (107)](../containers/107-dns.md)** (`/opt/dns-sync/sync.py`, cron */10) reconciles Technitium's named A-records → the **NetBird managed DNS zone** via the NetBird API (`/api/dns/zones/{id}/records`, PAT in sops `secrets/netbird-pat.yaml`). Mesh peers keep using the managed zone (which works); non-mesh LAN clients query Technitium directly; undefined names fall to the public IONOS wildcard (`.79`) — correct.
|
||||
- This **killed the manual drift** that caused the whole `auth`/`sso`/`nfs-export` saga. Never hand-edit the NetBird managed zone again — edit Technitium; the sync propagates.
|
||||
|
||||
**Cleanup done same day:** removed the inert Mac-Mini Technitium secondary (mesh-only, served nobody); reverted the primary's `zoneTransfer=Allow`; fixed `home-lab-dns` group → `[192.168.8.2]` (dropped the self-referencing Mac IP → now `1/1 Available`); deleted the vestigial `Proxmox Names` group.
|
||||
|
||||
> Reference: [scripts/dns-sync.py](../../../scripts/dns-sync.py). The sync's source of truth is Technitium; it **deletes** NetBird records absent from Technitium (so obsolete names like `files`, `photos-new` get reaped).
|
||||
|
||||
### 2026-06-06 — dns-sync cron finally installed (had been dormant since 2026-06-04 deployment)
|
||||
The `dns-sync.py` script on LXC 107 had been placed at `/opt/dns-sync/sync.py` on 2026-06-04 but **no crontab was configured** — the sync had never run automatically. The NetBird managed DNS zone was only in sync because manual runs happened during incident debugging.
|
||||
|
||||
**Fixed:** added `/etc/cron.d/dns-sync` (`*/10 * * * * root python3 /opt/dns-sync/sync.py >> /var/log/dns-sync.log 2>&1`).
|
||||
|
||||
Also added a Caddy backend health check cron on hubris (`/etc/cron.d/caddy-backend-health`) that runs `scripts/check-caddy-backends.sh` every 10 minutes.
|
||||
|
||||
### 2026-06-02 — 8 LXCs moved from DHCP to static IP
|
||||
All LXCs that Caddy reverse-proxies to by IP were on `ip=dhcp` and could float on reboot (arriman got a different lease mid-session and broke). Fixed via `pct set` + in-LXC `/etc/network/interfaces`. Affected: 101 jellyfin, 103 paperless, 104 gitea, 105 apps, 114 nextcloud, 118 elementsynapse, 120 mule-images, 121 caddy, 122 arriman. See [arriman changelog](../containers/122-arriman.md#changelog).
|
||||
|
||||
### 2026-06-01 — dnsmasq replaced by Technitium on [dns (107)](../containers/107-dns.md); LXC 124 retired
|
||||
Split-horizon DNS moved off [124](../containers/106-auth-outpost.md) to a dedicated **Technitium** LXC at **`192.168.8.2`** (zone: specific A overrides + wildcard→VPS + replicated MX/SPF/CAA). NetBird `home-lab-dns` nameserver group cut over to `192.168.8.2` (with `.180` as a now-dead fallback). dnsmasq stopped, all names verified via Technitium, **LXC 124 shut down**. **Caveat:** the [NetBird managed DNS zone](../containers/106-auth-outpost.md) still answers most app names *directly* (bypassing the nameserver group) — three overlapping DNS sources remain; see the single-source-of-truth decision (Phase 4). **Action needed:** update router DHCP DNS from the dead `.180` → `192.168.8.2` for any plain-LAN (non-mesh) clients.
|
||||
|
||||
### 2026-05-31 — `auth.hubris.network` re-pointed to the VPS (`82.165.190.79`)
|
||||
Authentik migrated off LXC 124 onto the VPS (see [investigation](../../sources/investigations/archive/2026-05-31-authentik-vps-migration.md)). The dnsmasq entry changed from `192.168.8.175` (home Caddy) to `82.165.190.79` (VPS traefik). This is the first LAN entry that intentionally points at the VPS rather than Caddy — `auth` is now a genuinely public service served directly from the VPS. **Gotcha logged:** the NetBird per-client resolver (`100.122.255.254`) caches dnsmasq answers and does **not** clear on `netbird down/up`; clients needed `/etc/hosts` overrides or `resolvectl flush-caches` to pick up the change. Since the service is now fully public, the long-term cleaner option is to drop the override entirely and let it fall through to the IONOS wildcard (which also points at the VPS).
|
||||
|
||||
### 2026-05-14 — `nfs-export.hubris.network` added (direct, non-HTTP)
|
||||
NFSv4 export server [nfs-export (102)](../containers/102-nfs-export.md) at `192.168.8.200`. Direct entry, not Caddy-fronted — NFS is L4, no HTTP reverse-proxy meaningful.
|
||||
|
||||
### 2026-05-14 — `zimaos.hubris.network` added (Caddy-fronted, standard pattern)
|
||||
New LAN entry for [100-zimaos](../vms/100-zimaos.md) → [caddy (121)](../containers/121-caddy.md) → `192.168.8.195`. Briefly pointed direct-to-VM during install for the initial smoke-test, then re-pointed once a Caddyfile block was added (`reverse_proxy 192.168.8.195` + IONOS DNS-01 TLS).
|
||||
|
||||
### 2026-05-13 — `plato.hubris.network` added; `files.hubris.network` removed
|
||||
New LAN-only entry for [plato (126)](../containers/index.md#recently-destroyed-kept-for-archaeology). Same day, the `files.hubris.network` entry for the just-decommissioned seafile experiment was dropped; queries now fall through to the public IONOS answer (no LAN backend).
|
||||
|
||||
### 2026-05-12 — `files.hubris.network` added (since removed 2026-05-13)
|
||||
Originally added for the seafile (LXC 125) Nextcloud-replacement evaluation. Pointed at 192.168.8.175 (Caddy reverse-proxied to 192.168.8.185:80). Entry removed when the experiment was torn down a day later.
|
||||
|
||||
### 2026-04-28 — wiki entry created
|
||||
Initial documentation. 16 active entries.
|
||||
|
||||
### 2026-04-22 — IONOS wildcard moved 74.118.126.4 → 82.165.190.79
|
||||
Public path now lands on the VPS traefik, not the old yunohost. Necessary for the [public ingress](ingress.md) pattern. The LAN dead-end semantics didn't change — public DNS still doesn't help LAN clients reach LAN-only services.
|
||||
|
||||
### 2026-04-22 — three caddy sites without DNS entries (jellyseerr, qbit, sab)
|
||||
Caddy + certs were working but LAN resolution failed because the dnsmasq lines weren't added. Lesson recorded; entries added later that day.
|
||||
|
||||
### 2026-04-21 — dnsmasq stood up on LXC 124
|
||||
Co-located with Authentik. Initial entries cover everything routed through Caddy.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Infrastructure — cross-cutting systems
|
||||
|
||||
The homelab's shared infrastructure: systems that span multiple nodes and
|
||||
are documented in their own pages. Each system below links to its full doc.
|
||||
|
||||
## Network
|
||||
|
||||
- **[Network](network.md)** — physical topology, subnets, routing, DHCP.
|
||||
Homelab `192.168.8.0/24` isolated from household `192.168.178.0/24`.
|
||||
Proxmox hubris acts as subnet router.
|
||||
- **[DNS — split-horizon](dns.md)** — Technitium DNS on LXC 107,
|
||||
`192.168.8.2:53`. `*.hubris.network` resolves to LAN IPs on the homelab
|
||||
network and to mesh addresses off-LAN.
|
||||
|
||||
## Connectivity / mesh
|
||||
|
||||
- **[Mesh — Tailscale → Netbird migration](mesh.md)** — overlay networking.
|
||||
Netbird is the preferred path; Tailscale is legacy.
|
||||
- **[SSH access](ssh-access.md)** — dual-server SSH (OpenSSH + Netbird SSH)
|
||||
on hubris, key distribution.
|
||||
|
||||
## Public ingress
|
||||
|
||||
- **[Public ingress — VPS traefik + cert mirror](ingress.md)** — how home
|
||||
services reach the open internet. Two-stage: VPS traefik (IONOS) terminates
|
||||
TLS, proxies over Netbird to home Caddy.
|
||||
- **[Caddy reverse proxy](../containers/121-caddy.md)** — LAN endpoint.
|
||||
Terminates TLS for every `*.hubris.network` hostname, forwards to backends.
|
||||
|
||||
## Storage
|
||||
|
||||
- **[Media permissions — GID 10000 standard](media-permissions.md)** — shared
|
||||
group permission model across all LXCs that read/write the media library.
|
||||
- **[Backups — rclone → Proton Drive](backups.md)** — off-host backup strategy.
|
||||
LXC 132 handles rclone to Proton Drive; restic-on-USB deprecated.
|
||||
|
||||
## Identity & access
|
||||
|
||||
- **[Authentik SSO](../containers/106-auth-outpost.md)** — identity provider.
|
||||
Core server runs on the VPS; LAN forward-auth outpost at LXC 106.
|
||||
OIDC providers configured for Jellyfin, Jellyseerr, Sabnzbd, qBittorrent,
|
||||
Yuvomi, and more.
|
||||
|
||||
## Management & automation
|
||||
|
||||
- **[Homelab context distribution](homelab-context.md)** — `/opt/homelab-context`
|
||||
clone, MCP server, secrets issuance, cross-client sync.
|
||||
- **[Auto-deploy — gitea-webhook pipelines](auto-deploy.md)** — push-to-deploy
|
||||
for Caddy config, mule-image, and other tracked repos.
|
||||
- **[Monitoring](monitoring.md)** — health checks, watchdogs, alerting
|
||||
(migrated from claudio-bot to Hermes cron).
|
||||
- **[VPS hardening](vps-hardening.md)** — IONOS netbird VPS: fail2ban,
|
||||
nftables, OIDC SSH, security posture.
|
||||
|
||||
## Topology
|
||||
|
||||
- **[Topology diagram (generated)](topology.md)** — Mermaid graph of compute,
|
||||
ingress routing, and storage mounts. Auto-generated from `inventory.yaml`
|
||||
by `oikos/gen-topology.py` (Python — Go DB-native replacement planned).
|
||||
|
||||
## Related
|
||||
|
||||
- [README](../../../README.md) — entry point
|
||||
- [Containers index](../containers/index.md)
|
||||
- [Operations cheatsheet](../../../.agents/operations/commands.md)
|
||||
- [OIKOS operating model](../../../.agents/OIKOS.md)
|
||||
@@ -1,60 +0,0 @@
|
||||
# Monitoring — Hermes health watchdog
|
||||
|
||||
Homelab health monitoring via Hermes Agent on mac-mini. Replaced the legacy
|
||||
`claudio-monitor` + `claudio-bot` IPC pipeline on 2026-06-04.
|
||||
|
||||
## Current approach
|
||||
|
||||
Two layers:
|
||||
|
||||
1. **On-demand:** ask Hermes "how's the homelab?" or run `homelab health` — loads
|
||||
the `homelab-hardware-health` skill, checks hardware temps, LXC resources,
|
||||
service reachability, and apt/docker drift across all hosts.
|
||||
|
||||
2. **Cron watchdog:** `homelab-health-watchdog` runs every 15 minutes via Hermes
|
||||
cron. Silent when healthy. When thresholds breach, sends an actionable alert
|
||||
to Matrix (`@dtoro:avispero`) with options the user can reply to directly
|
||||
(e.g. "resize rootfs", "investigate", "snooze 24h"). Hermes takes action on
|
||||
the selected option via SSH.
|
||||
|
||||
Thresholds: LXC disk >80% warn/>90% critical, NVMe >60°C/>70°C, CPU >70°C/>80°C,
|
||||
apt >10/>50 upgradable, services down.
|
||||
|
||||
Home Assistant pulls PVE metrics independently via its Proxmox VE integration
|
||||
(unaffected by this change).
|
||||
|
||||
## Legacy: claudio-monitor (deprecated 2026-06-04)
|
||||
|
||||
The old system was a bash watchdog on hubris (`claudio-monitor.timer`, every 5
|
||||
min) that POSTed alerts to a Matrix bot (`@claudio:avispero`) via an IPC server
|
||||
on LXC 123:9090. All components decommissioned:
|
||||
|
||||
| Component | Fate |
|
||||
|-----------|------|
|
||||
| LXC 123 (claudio-bot) | Destroyed 2026-06-04 |
|
||||
| `dtoro/claudio-bot` | Archived (read-only) on Gitea |
|
||||
| `dtoro/claudio-monitor` | Archived (read-only) on Gitea |
|
||||
| `claudio-monitor.timer` | Disabled on hubris |
|
||||
| `/opt/claudio-monitor/` | Still on hubris (cleanup pending) |
|
||||
| `/etc/claudio-monitor/` | Still on hubris (cleanup pending) |
|
||||
|
||||
For the full deprecation plan, see `plans/2026-06-04_130000-deprecate-claudio-bot.md`.
|
||||
|
||||
## Related pages
|
||||
- [Hubris host](../hosts/hubris.md)
|
||||
- [HAOS VM (108)](../vms/108-haos.md)
|
||||
- [Backups (disabled)](backups.md)
|
||||
- [Homelab context distribution](homelab-context.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-04 — migrated to Hermes health watchdog
|
||||
claudio-monitor + claudio-bot IPC pipeline replaced by Hermes-native monitoring.
|
||||
On-demand `homelab health` via extended skill; 15-min cron watchdog with actionable
|
||||
Matrix alerts. LXC 123 destroyed, repos archived.
|
||||
|
||||
### 2026-04-28 — wiki entry created
|
||||
Initial documentation.
|
||||
|
||||
### 2026-04-21 — claudio-monitor stood up; thermal-watch removed
|
||||
General health monitor with per-LXC checks. MQTT/REST push paths ripped out.
|
||||
@@ -1,88 +0,0 @@
|
||||
# Network
|
||||
|
||||
Physical and logical network topology for the homelab.
|
||||
|
||||
## Why
|
||||
|
||||
The homelab runs on a dedicated internal subnet (`192.168.8.0/24`) isolated from the main household LAN (`192.168.178.0/24`). Isolation is enforced at Proxmox: LXC/VM traffic is bridged only on the internal `vmbr0` bridge; Proxmox routes packets out to Fritz!Box via `vmbr1`. The main LAN cannot reach homelab services directly without a Fritz!Box static route (which is configured to allow inbound).
|
||||
|
||||
Fritz!OS 8.x does not support second IP networks on LAN ports, so Proxmox (`hubris`) acts as the subnet router rather than the Fritz!Box.
|
||||
|
||||
## Hardware
|
||||
|
||||
| Device | Role |
|
||||
|---|---|
|
||||
| Fritz!Box 7590 | Main router / ISP gateway (`192.168.178.1`) |
|
||||
| SODOLA 5-Port 2.5Gbit Managed | Homelab switch — flat L2, all ports native |
|
||||
| hubris (Proxmox) | Subnet router — routes between `192.168.8.0/24` and `192.168.178.0/24` |
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
ISP
|
||||
└── Fritz!Box 7590 (192.168.178.1)
|
||||
│ static route: 192.168.8.0/24 → 192.168.178.10
|
||||
│
|
||||
└── SODOLA 5-Port 2.5Gbit
|
||||
├── Port 1 uplink → Fritz!Box LAN
|
||||
├── Port 2 hubris eno1 → vmbr1 (192.168.178.10)
|
||||
├── Port 3 [device]
|
||||
├── Port 4 [device]
|
||||
└── Port 5 spare
|
||||
|
||||
hubris internal bridges:
|
||||
vmbr1 192.168.178.10/24 eno1 (uplink, DHCP-reserved) gateway 192.168.178.1
|
||||
vmbr0 192.168.8.77/24 no physical port (internal)
|
||||
192.168.8.1/24 alias — LXC default gateway
|
||||
├── all 16 LXCs
|
||||
└── HAOS VM
|
||||
```
|
||||
|
||||
## Subnets
|
||||
|
||||
| Subnet | Gateway | Purpose |
|
||||
|---|---|---|
|
||||
| `192.168.178.0/24` | `192.168.178.1` | Household LAN — laptops, phones, Fritz!Box DHCP |
|
||||
| `192.168.8.0/24` | `192.168.8.1` (Proxmox `vmbr0` alias) | Homelab — all LXCs and VMs |
|
||||
|
||||
## DHCP
|
||||
|
||||
- **Household (`192.168.178.x`)**: Fritz!Box built-in DHCP. Proxmox `vmbr1` has a reservation: MAC `84:47:09:6b:e7:58` → `192.168.178.10`.
|
||||
- **Homelab (`192.168.8.x`)**: Technitium on [CT 107](../containers/107-dns.md) at `192.168.8.2`. Range `192.168.8.241–192.168.8.254`, gateway `192.168.8.1`, DNS `192.168.8.2`.
|
||||
|
||||
Static IPs span `.101–.239` (all LXCs, VMs, and workstations). DHCP pool narrowed to `.241–.254` (2026-06-03) to avoid overlap and IP conflicts.
|
||||
|
||||
## DNS
|
||||
|
||||
Split-horizon DNS for `*.hubris.network` served by Technitium on [CT 107](../containers/107-dns.md) at `192.168.8.2:53`. See [dns.md](dns.md) for full detail.
|
||||
|
||||
## Routing
|
||||
|
||||
Proxmox has `net.ipv4.ip_forward=1` (already enabled by PVE). Packets from LXCs on `vmbr0` destined for the internet exit via `vmbr1` → Fritz!Box. Fritz!Box masquerades all outbound WAN traffic. Fritz!Box has a static route (`192.168.8.0/24 → 192.168.178.10`) so return traffic reaches the LXCs.
|
||||
|
||||
No NAT on Proxmox — traffic flows without double-NAT.
|
||||
|
||||
## Remote access
|
||||
|
||||
- **NetBird mesh** — primary path for remote administration. Authenticated via [Authentik on the VPS](../../../vps/).
|
||||
- **Tailscale** — legacy, being phased out. See [mesh.md](mesh.md).
|
||||
|
||||
## Related
|
||||
|
||||
- [DNS](dns.md) — split-horizon config and entry list
|
||||
- [Ingress](ingress.md) — public entry points via VPS traefik
|
||||
- [Mesh](mesh.md) — NetBird / Tailscale VPN overlay
|
||||
- [hosts/hubris.md](../hosts/hubris.md) — Proxmox host (vmbr0/vmbr1 config)
|
||||
- [CT 107 — dns](../containers/107-dns.md) — Technitium DNS + DHCP server
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-17 — Fritz!Box DNSv4 server set to Technitium (192.168.8.2)
|
||||
Household LAN clients (192.168.178.x) now resolve `*.hubris.network` to LAN IPs. Configured in Fritz!Box at Internet → Filter → DNS Server → DNSv4 Server → "Use other DNSv4 servers" → Preferred = `192.168.8.2`. No per-device or Netbird setup needed.
|
||||
Previous pool `.100–.240` overlapped with all static LXCs/VMs (` .101–.239`), creating IP conflict risk (DHCP could hand out an IP that a static service expects). Shrunk pool to `.241–.254` via Technitium API. No services re-IP'd. 11 stale DHCP leases in `.101–.110` will expire naturally. **Open:** ZimaOS (VM 100) holds DHCP lease `.103` but inventory expects `.195` — needs static IP set inside VM. See [plan](../../../.hermes/plans/2026-06-03_223218-dhcp-pool-exclude-static-ips.md).
|
||||
|
||||
### 2026-06-02 — Executed migration; Proxmox as subnet router
|
||||
Fritz!OS 8.x does not support second IP networks on LAN ports, so the final design uses Proxmox as the router: `vmbr1` (eno1 → SODOLA → Fritz!Box) is the uplink at `192.168.178.10`; `vmbr0` is a portless internal bridge with `192.168.8.1` alias as the LXC gateway. Technitium DHCP enabled for `192.168.8.100–240`. Caddy service unit was missing and recreated. See [migration plan](../../../plans/done/2026-06-01-slate-ax-to-sodola-migration.md).
|
||||
|
||||
### 2026-06-01 — Initial network doc; Slate AX retired; SODOLA switch added
|
||||
Replaced the GL.iNet Slate AX sub-router with the SODOLA 5-Port 2.5Gbit managed switch. Eliminated double-NAT. See [migration plan](../../../plans/done/2026-06-01-slate-ax-to-sodola-migration.md).
|
||||
@@ -1,206 +0,0 @@
|
||||
# SSH access
|
||||
|
||||
How to reach every host in the fleet from any workstation, with LAN as
|
||||
the primary path and Netbird as the automatic backup.
|
||||
|
||||
## Architecture
|
||||
|
||||
SSH access relies on three layers:
|
||||
|
||||
1. **Homelab inventory (`inventory.yaml`)** — the single source of truth
|
||||
for every host's LAN IP, Netbird addresses, SSH user, and port.
|
||||
2. **Key distribution (`ssh/deploy-keys.sh`)** — deploys workstation SSH
|
||||
public keys to hubris and every running LXC, so any key-authorized
|
||||
workstation can log in anywhere.
|
||||
3. **Config generation (`homelab ssh-config --install`)** — generates
|
||||
`~/.ssh/config.d/homelab` with short hostname aliases for every host,
|
||||
using LAN IPs (routed via Netbird's `192.168.8.0/24` subnet route when
|
||||
off-LAN) with Netbird FQDN fallbacks (`<name>-mesh`) for roaming
|
||||
workstations.
|
||||
|
||||
### How it works
|
||||
|
||||
- **From on-LAN:** `ssh gitea` resolves to `192.168.8.121` directly.
|
||||
- **From off-LAN (Netbird):** The same `192.168.8.121` works because
|
||||
hubris routes the `192.168.8.0/24` subnet through Netbird.
|
||||
- **Roaming workstations:** `ssh mac-mini-mesh` or `ssh republic-laptop-mesh`
|
||||
uses the Netbird FQDN as a fallback when the workstation is off its
|
||||
home subnet.
|
||||
|
||||
The `homelab ssh <host>` CLI command also has built-in LAN probing:
|
||||
it tries a 1.5s TCP connect to the LAN IP, and if that fails, falls
|
||||
back to the Netbird FQDN.
|
||||
|
||||
## Key distribution
|
||||
|
||||
Each workstation's SSH public key lives in the repo at:
|
||||
`ssh/authorized_keys/<hostname>.pub`
|
||||
|
||||
To deploy or re-deploy all workstation keys to hubris + every running LXC:
|
||||
|
||||
```bash
|
||||
# From hubris (or via homelab pct):
|
||||
sudo bash /opt/homelab-context/ssh/deploy-keys.sh
|
||||
|
||||
# Or from any workstation:
|
||||
ssh root@192.168.8.77 "bash /opt/homelab-context/ssh/deploy-keys.sh"
|
||||
```
|
||||
|
||||
This script:
|
||||
- Reads all `.pub` files from `ssh/authorized_keys/`
|
||||
- Adds any missing keys to `/etc/pve/priv/authorized_keys` on hubris
|
||||
- For each running LXC, appends keys to `/root/.ssh/authorized_keys`
|
||||
- Is idempotent — skips keys already present
|
||||
|
||||
## Config generation
|
||||
|
||||
To generate the SSH config on any workstation:
|
||||
|
||||
```bash
|
||||
homelab ssh-config --install
|
||||
```
|
||||
|
||||
This writes to `~/.ssh/config.d/homelab` and ensures
|
||||
`Include ~/.ssh/config.d/homelab` is present in `~/.ssh/config`.
|
||||
|
||||
The config is regenerated automatically on every `homelab sync` (which
|
||||
kicks the 5-minute context sync timer).
|
||||
|
||||
## Adding a new workstation
|
||||
|
||||
When onboarding a new machine:
|
||||
|
||||
1. Hostname must match an entry in `inventory.yaml`.
|
||||
2. If the workstation will be on the LAN, add its `lan_ip` to
|
||||
`inventory.yaml` and push. This gives it a primary LAN entry in the
|
||||
generated SSH config.
|
||||
3. Enable SSH Remote Login:
|
||||
- **macOS:** `sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist`
|
||||
- **Linux:** `sudo systemctl enable --now sshd`
|
||||
4. Generate an SSH keypair if one doesn't exist:
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -a 100
|
||||
```
|
||||
5. Publish the public key to the repo:
|
||||
```bash
|
||||
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/<hostname>.pub
|
||||
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add <hostname> pubkey' && git push
|
||||
```
|
||||
6. Deploy the key to all hosts:
|
||||
```bash
|
||||
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
|
||||
```
|
||||
7. Generate the local SSH config:
|
||||
```bash
|
||||
homelab ssh-config --install
|
||||
```
|
||||
|
||||
## Hosts
|
||||
|
||||
### Hubris + strong (PVE cluster: `Homelab`)
|
||||
|
||||
Both nodes share `/etc/pve/priv/authorized_keys` — it's Proxmox
|
||||
cluster-synced, so a key added on either node is authorized on both.
|
||||
|
||||
| Detail | hubris | strong |
|
||||
|--------|--------|-----------|
|
||||
| LAN IP | `192.168.8.77` | `192.168.178.181` |
|
||||
| Cluster node name | `hubris` | `strong` (OS hostname kept as-is from install) |
|
||||
| Netbird | `100.122.38.109` (`proxmox-server.netbird.selfhosted`) | not enrolled yet |
|
||||
| Netbird SSH port | `22022` (mesh-only, OIDC auth) | n/a |
|
||||
| SSH user | `root` | `root` |
|
||||
|
||||
Authorized root keys currently deployed (cluster-wide):
|
||||
- `root@hubris` (self, RSA)
|
||||
- `d.toro.v@pm.me` (ed25519) — mac-mini
|
||||
- `root@strong` (RSA) — strong's own key, added 2026-07-01 for the cluster join
|
||||
|
||||
### LXCs
|
||||
|
||||
Every LXC at `192.168.8.x` accepts root SSH via authorized_keys. Keys
|
||||
are managed by `ssh/deploy-keys.sh`. SSH user is `root`.
|
||||
|
||||
| LXC | Name | LAN IP | Role |
|
||||
|-----|------|--------|------|
|
||||
| 101 | jellyfin | `192.168.8.206` | media-server |
|
||||
| 102 | nfs-export | `192.168.8.200` | storage-export |
|
||||
| 103 | paperless | `192.168.8.130` | document-archive |
|
||||
| 104 | gitea | `192.168.8.121` | git-server |
|
||||
| 105 | apps | `192.168.8.205` | docker-apps |
|
||||
| 106 | auth-outpost | `192.168.8.184` | authentik-outpost |
|
||||
| 107 | dns | `192.168.8.185` | dns-helper |
|
||||
| 114 | nextcloud | `192.168.8.224` | file-sync |
|
||||
| 118 | elementsynapse | `192.168.8.239` | matrix-server |
|
||||
| 119 | sophia | `192.168.8.157` | workshop |
|
||||
| 120 | mule-images | `192.168.8.136` | photo-management |
|
||||
| 121 | caddy | `192.168.8.175` | reverse-proxy |
|
||||
| 122 | arriman | `192.168.8.132` | arr-stack |
|
||||
|
||||
### Workstations
|
||||
|
||||
| Name | OS | LAN IP | Netbird FQDN | SSH user |
|
||||
|------|----|--------|--------------|----------|
|
||||
| mac-mini | macOS | `192.168.8.174` | `mac-mini-234-17.netbird.selfhosted` | `dtoro` |
|
||||
| republic-laptop | Linux | TBD | `republic-laptop.netbird.selfhosted` | `dtoro` |
|
||||
|
||||
strong moved out of this table 2026-07-01 — it's a Proxmox host now, see the cluster table above.
|
||||
|
||||
### VPS (external)
|
||||
|
||||
| Detail | Value |
|
||||
|--------|-------|
|
||||
| Public IP | `82.165.190.79` |
|
||||
| Netbird | `100.122.165.149` (FQDN: `netbird-ionos.netbird.selfhosted`) |
|
||||
| SSH user | `root` |
|
||||
| Access | Mesh-only — public port 22 is blocked by nftables. Key-only auth. |
|
||||
|
||||
## VPS
|
||||
|
||||
Access is mesh-only. From a mesh-connected peer:
|
||||
|
||||
```bash
|
||||
ssh root@100.122.165.149
|
||||
ssh root@netbird-ionos.netbird.selfhosted
|
||||
# or via homelab:
|
||||
homelab ssh netbird-vps
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# From any workstation after running homelab ssh-config --install:
|
||||
for name in hubris gitea apps sophia paperless caddy jellyfin nextcloud; do
|
||||
ssh -o BatchMode=yes "$name" "hostname" && echo "$name OK"
|
||||
done
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Mesh migration](mesh.md)
|
||||
- [VPS hardening](vps-hardening.md)
|
||||
- [Agent enrollment](../../../.agents/operations/agent-enrollment.md)
|
||||
- [Homelab CLI](../../../bin/homelab)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-01 — strong reformatted to Proxmox, joined cluster; table corrected
|
||||
strong moved from the Workstations table to the PVE-cluster table (was showing a stale `192.168.8.133`, never actually reachable — the real LAN IP has always been `192.168.178.181`, matching hosts/strong.yaml). Root key access bootstrapped via one-time console password, then key-only going forward. See [hosts/hubris.md#cluster](../hosts/hubris.md#cluster) and [hosts/strong.md](../hosts/strong.md).
|
||||
|
||||
### 2026-06-02 — universal SSH reachability
|
||||
|
||||
Replaced ad-hoc per-workstation SSH configs with inventory-generated
|
||||
configs (`ssh/gen-config.py`, `homelab ssh-config`). Added centralized
|
||||
key distribution (`ssh/deploy-keys.sh`, `ssh/authorized_keys/`). All
|
||||
LXCs now accept root SSH from any workstation whose pubkey is in the
|
||||
repo. mac-mini Remote Login enabled. Netbird subnet route
|
||||
(192.168.8.0/24 via hubris) provides off-LAN reachability for all LAN
|
||||
IPs.
|
||||
|
||||
### 2026-04-28 — wiki entry created
|
||||
Initial documentation.
|
||||
|
||||
### 2026-04-23 — VPS SSH hardened to mesh-only
|
||||
Public `:22` blocked at nftables. Key-only sshd.
|
||||
|
||||
### 2026-04-22 — iMac key authorized on hubris
|
||||
`d.toro.v@pm.me` added to `/etc/pve/priv/authorized_keys`.
|
||||
@@ -1,96 +0,0 @@
|
||||
<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->
|
||||
<!-- Do NOT edit by hand - your changes will be overwritten. -->
|
||||
|
||||
# Topology (generated)
|
||||
|
||||
Source: [inventory.yaml](../../../inventory.yaml) — 2 hypervisors, 20 LXCs, 2 VMs, 2 workstations, 18 services.
|
||||
Edge semantics: [oikos/ontology.yaml](../../../oikos/ontology.yaml). Operating model: [OIKOS.md](../../../.agents/OIKOS.md).
|
||||
|
||||
## Compute & ingress
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph hubris_sub["hubris (Proxmox)"]
|
||||
trmnl["trmnl<br/>LXC 128<br/>trmnl-middleware<br/>192.168.8.211"]
|
||||
nfs_export["nfs-export<br/>LXC 102<br/>storage-export<br/>192.168.8.200"]
|
||||
paperless["paperless<br/>LXC 103<br/>document-archive<br/>192.168.8.130"]
|
||||
gitea["gitea<br/>LXC 104<br/>git-server<br/>192.168.8.121"]
|
||||
apps["apps<br/>LXC 105<br/>docker-apps<br/>192.168.8.205"]
|
||||
auth_outpost["auth-outpost<br/>LXC 106<br/>authentik-gateway<br/>192.168.8.6"]
|
||||
dns["dns<br/>LXC 107<br/>dns-server<br/>192.168.8.2"]
|
||||
nextcloud["nextcloud<br/>LXC 114<br/>file-sync<br/>192.168.8.224"]
|
||||
sophia["sophia<br/>LXC 119<br/>workshop<br/>192.168.8.109"]
|
||||
mule_images["mule-images<br/>LXC 120<br/>photo-management<br/>192.168.8.136"]
|
||||
caddy["caddy<br/>LXC 121<br/>reverse-proxy<br/>192.168.8.175"]
|
||||
teddycloud["teddycloud<br/>LXC 131<br/>teddycloud<br/>192.168.8.150"]
|
||||
zimaos["zimaos<br/>VM 100<br/>nas-frontend-eval<br/>192.168.8.195"]
|
||||
haos["haos<br/>VM 108<br/>home-automation<br/>192.168.8.101"]
|
||||
end
|
||||
subgraph strong_sub["strong (Proxmox)"]
|
||||
house["house<br/>LXC 129<br/>family-planner<br/>192.168.8.244"]
|
||||
jellyfin["jellyfin<br/>LXC 101<br/>media-server<br/>192.168.8.246"]
|
||||
elementsynapse["elementsynapse<br/>LXC 118<br/>matrix-server<br/>192.168.8.242"]
|
||||
arriman["arriman<br/>LXC 122<br/>arr-stack<br/>192.168.8.245"]
|
||||
grimmory["grimmory<br/>LXC 130<br/>book-library<br/>192.168.8.247"]
|
||||
seanime["seanime<br/>LXC 133<br/>anime-media-server<br/>192.168.8.248"]
|
||||
romm["romm<br/>LXC 134<br/>rom-manager<br/>192.168.8.249"]
|
||||
end
|
||||
rclone["rclone<br/>lxc<br/>backup"]
|
||||
republic_laptop([republic-laptop<br/>workstation<br/>primary-dev])
|
||||
mac_mini([mac-mini<br/>workstation<br/>dev<br/>192.168.178.182])
|
||||
netbird_vps[[netbird-vps<br/>external<br/>netbird-mgmt]]
|
||||
url_artifacto(["artifacto.hubris.network"]) -->|routes-to| apps
|
||||
url_authentik(["auth.hubris.network"]) -->|routes-to| netbird_vps
|
||||
url_gitea(["git.hubris.network"]) -->|routes-to| gitea
|
||||
url_homelab_mcp(["mcp.hubris.network"]) -->|routes-to| apps
|
||||
url_jellyfin(["media.hubris.network"]) -->|routes-to| jellyfin
|
||||
url_matrix(["matrix.hubris.network"]) -->|routes-to| elementsynapse
|
||||
url_nextcloud(["cloud.hubris.network"]) -->|routes-to| nextcloud
|
||||
url_paperless(["paperless.hubris.network"]) -->|routes-to| paperless
|
||||
url_photos(["photos.hubris.network"]) -->|routes-to| mule_images
|
||||
url_proxmox_ui(["proxmox.hubris.network"]) -->|routes-to| hubris_sub
|
||||
url_secrets_issuance(["secrets.hubris.network"]) -->|routes-to| apps
|
||||
url_teddycloud(["teddy.hubris.network"]) -->|routes-to| teddycloud
|
||||
url_trmnl(["trmnl.hubris.network"]) -->|routes-to| trmnl
|
||||
url_zimaos(["zimaos.hubris.network"]) -->|routes-to| zimaos
|
||||
```
|
||||
|
||||
## Storage (mounts)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
mnt_library[("/mnt/library")]
|
||||
mnt_media_local[("/mnt/media_local")]
|
||||
mnt_media_local_anime[("/mnt/media_local/anime")]
|
||||
apps["apps"] -->|mounts| mnt_library
|
||||
arriman["arriman"] -->|mounts| mnt_media_local
|
||||
gitea["gitea"] -->|mounts| mnt_library
|
||||
grimmory["grimmory"] -->|mounts| mnt_media_local
|
||||
hubris["hubris"] -->|mounts| mnt_library
|
||||
jellyfin["jellyfin"] -->|mounts| mnt_media_local
|
||||
mule_images["mule-images"] -->|mounts| mnt_library
|
||||
nextcloud["nextcloud"] -->|mounts| mnt_library
|
||||
paperless["paperless"] -->|mounts| mnt_library
|
||||
romm["romm"] -->|mounts| mnt_media_local
|
||||
seanime["seanime"] -->|mounts| mnt_media_local_anime
|
||||
sophia["sophia"] -->|mounts| mnt_library
|
||||
teddycloud["teddycloud"] -->|mounts| mnt_library
|
||||
```
|
||||
|
||||
## Archaeology (destroyed nodes)
|
||||
|
||||
| Node | ID | Destroyed | Reason |
|
||||
|---|---|---|---|
|
||||
| plato | 126 | 2026-06-28 | notes workspace decommissioned; data retained at /mnt/library/documents/plato |
|
||||
| claudio-bot | 123 | 2026-06-04 | replaced by Hermes Agent on mac-mini; monitoring moved to homelab-health-watchdog cron |
|
||||
| mule-photos-new | 127 | 2026-05-22 | PhotoPrism test stack promoted to LXC 120 (Mulimage 2.0 merge) |
|
||||
| heaper | 116 | 2026-05-14 | decommissioned; data retained at /mnt/library/heaper |
|
||||
| syncthing | 109 | 2026-05-14 | decommissioned; library subtree was empty |
|
||||
| seafile | 125 | 2026-05-13 | Seafile Pro evaluation rejected; files.hubris.network removed from caddy + dns |
|
||||
| arr-yunohost | 100 | 2026-04-28 | migrated to docker stack on arriman (LXC 122) |
|
||||
| flaresolverr | 106 | 2026-04-28 | folded into the arriman docker compose |
|
||||
| marimo | 107 | 2026-04-28 | decommissioned |
|
||||
| photoprism | 110 | 2026-04-28 | replaced by mule-images (LXC 120) |
|
||||
| karakeep | 111 | 2026-04-28 | decommissioned |
|
||||
| immich | 112 | 2026-04-28 | replaced by mule-images (LXC 120) |
|
||||
| reticulum | 115 | 2026-04-28 | decommissioned |
|
||||
@@ -1,109 +0,0 @@
|
||||
# 2026-05-31 — Authentik migrated from LXC 124 to the VPS
|
||||
|
||||
## Summary
|
||||
|
||||
The NetBird management server (on the [VPS](../../../wiki/infrastructure/ingress.md)) crash-looped 1200+ times because it fetches the Authentik OIDC discovery document on startup, and Authentik was only reachable via the NetBird mesh — which was down *because* mgmt couldn't start. A classic bootstrap deadlock: **mgmt needs OIDC → OIDC needs the mesh → the mesh needs mgmt.**
|
||||
|
||||
Resolved by moving Authentik off [LXC 124](../../../wiki/containers/106-auth-outpost.md) onto the VPS itself, so `auth.hubris.network` resolves to a container co-located with netbird-mgmt — no mesh dependency. A `depends_on: condition: service_healthy` on the mgmt service makes the deadlock structurally impossible to recur.
|
||||
|
||||
The full Authentik Postgres DB (all users, apps, passwords, groups) was migrated, so every gated app keeps working with no per-app reconfiguration.
|
||||
|
||||
## Timeline
|
||||
|
||||
### Trigger
|
||||
`netbird status` on clients showed `Management: Disconnected`; `netbird.hubris.network` unreachable. VPS `docker logs netbird-mgmt`:
|
||||
```
|
||||
Error: failed reading provided config file: /etc/netbird/management.json:
|
||||
OIDC configuration request returned status 504 with response: Gateway Timeout
|
||||
```
|
||||
The 504 came from VPS traefik trying to reach the old `auth-authentik` backend (`https://192.168.8.175`, home Caddy) over the down mesh.
|
||||
|
||||
### Fix — Phase A (restore the mesh)
|
||||
1. Stood up Authentik on the VPS in `/opt/docker-compose.yml`: `authentik-postgres`, `authentik-redis`, `authentik-server`, `authentik-worker` (image `ghcr.io/goauthentik/server:2026.5.2`), on a dedicated `auth` Docker network (`172.30.1.0/24`). Traefik joined both `netbird` and `auth` networks.
|
||||
2. Removed the `auth-authentik` → `192.168.8.175` backend from `/opt/traefik-dynamic.yaml`; Authentik now routed via Docker provider labels.
|
||||
3. Added `depends_on: authentik-server: condition: service_healthy` to the mgmt service.
|
||||
4. Created the NetBird OAuth2 provider/app manually (only manual step), then mgmt auto-recovered within 30 s.
|
||||
|
||||
### Fix — Phase B (full data migration)
|
||||
Once the mesh was back, the homelab DB was reachable:
|
||||
1. `ssh proxmox 'pct exec 124 -- docker exec authentik-postgresql-1 pg_dump -U authentik authentik'` → gzip.
|
||||
2. Stopped VPS authentik server/worker, dropped + recreated the VPS DB (connect to the `postgres` DB to drop `authentik`), restored the dump.
|
||||
3. **Swapped `AUTHENTIK_SECRET_KEY` in `/opt/authentik.env` to the homelab's key** — Authentik encrypts OAuth2 client secrets in the DB with this key; a mismatch makes every client secret unreadable and silently breaks all SSO.
|
||||
4. Restarted Authentik. All users / apps / passwords / groups present.
|
||||
|
||||
Scripts left on the VPS: `/opt/migrate-authentik.sh` (re-runnable), `/opt/backup-authentik.sh` (daily cron, 14-day retention).
|
||||
|
||||
### Fix — DNS cutover (the long tail)
|
||||
The real reason the browser kept hitting the *old* Authentik even after the VPS one was healthy: split-horizon dnsmasq on LXC 124 still had `address=/auth.hubris.network/192.168.8.175`, and the NetBird per-client resolver (`100.122.255.254`) cached it. Symptom: the login page console logged `authentik(early): version 2026.2.2` (the old LXC) instead of `2026.5.2` (the VPS).
|
||||
|
||||
- Updated dnsmasq: `address=/auth.hubris.network/82.165.190.79`, `systemctl restart dnsmasq`.
|
||||
- `netbird down && netbird up` does **not** reliably flush the NetBird resolver cache. Immediate per-client fixes: `/etc/hosts` override (mac), `sudo resolvectl flush-caches` (Linux), `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder` (mac).
|
||||
|
||||
## Issues hit during the migration (and fixes)
|
||||
|
||||
1. **Redirect URI error.** The restored DB had redirect URIs in `REGEX` matching mode; in Authentik 2026.5.x they failed to match. Fixed by switching to `STRICT` exact matching (Django ORM, `RedirectURIMatchingMode.STRICT`). Set all four: `http://localhost:53000/` (CLI), `https://netbird.hubris.network/{peers,nb-auth,nb-silent-auth}`.
|
||||
2. **Only the password field showed (no username).** NetBird passes `login_hint=<email>` in the OAuth2 URL → Authentik pre-identifies and skips the identification stage. Expected behavior; not a bug.
|
||||
3. **"Request has been denied. Unknown error."** Several overlapping causes: wrong password (reset via Django shell), reputation lockout after repeated failures (`Reputation.objects.all().delete()` — see [124-authentik](../../../wiki/containers/106-auth-outpost.md)), and **broken default expression policies**. The restored DB carried 8 default policies authored in old `return`-style syntax incompatible with 2026.5.x's eval context; `ak apply_blueprints` re-applied the current defaults.
|
||||
4. **Browser ran stale frontend JS.** Console showed `version 2026.2.2` while the backend was `2026.5.2` — because DNS still pointed at the old LXC (see DNS cutover above), not a cache issue.
|
||||
5. **WebAuthn devices dead post-migration.** Passkeys are device/origin-bound and don't survive a host move. Deleted all WebAuthn devices via Django ORM; users must re-register MFA.
|
||||
|
||||
## Architecture delta
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| Authentik host | [LXC 124](../../../wiki/containers/106-auth-outpost.md) `192.168.8.180` | VPS `82.165.190.79`, `auth` Docker net `172.30.1.0/24` |
|
||||
| Version | `2026.2.2` | `2026.5.2` |
|
||||
| `auth.hubris.network` (LAN) | dnsmasq → `192.168.8.175` (Caddy) | dnsmasq → `82.165.190.79` (VPS traefik) |
|
||||
| `auth.hubris.network` (public) | IONOS wildcard → VPS → mesh → LXC 124 | IONOS wildcard → VPS → local container |
|
||||
| Reachability | mesh-only (split-horizon) | **public** (auth UI now exposed on the open internet) |
|
||||
| Redis | none (docs said 2026.x dropped it) | present (this deploy uses Redis) |
|
||||
| DB engine | sqlite? (`StoreConfig` in mgmt) / Postgres for Authentik | Postgres, separate from netbird |
|
||||
|
||||
> **Security note:** the Authentik admin UI is now publicly reachable. Mitigated with a traefik `admin-allowlist` ipAllowList middleware on `/if/admin/` (currently `5.61.168.0/24`). Login/flow endpoints are public by necessity. The `auth` Docker network isolates Postgres/Redis from the netbird containers.
|
||||
|
||||
## Lessons learned
|
||||
|
||||
1. **Never put the IdP behind the VPN it authenticates.** The circular dependency is invisible until a restart, then it's total.
|
||||
2. **`depends_on: condition: service_healthy` is the structural fix.** Docker refuses to start mgmt until Authentik is healthy, regardless of boot order.
|
||||
3. **Migrating an Authentik DB requires carrying its `AUTHENTIK_SECRET_KEY`.** It encrypts client secrets at rest; a mismatch breaks SSO silently with no useful error.
|
||||
4. **Default expression policies break across major Authentik versions.** Always run `ak apply_blueprints` after restoring an older DB into a newer Authentik.
|
||||
5. **Split-horizon DNS + a VPN resolver cache = an invisible stale answer.** The NetBird resolver (`100.122.255.254`) outlives `netbird down/up`. Removing the dnsmasq override (fall through to public DNS) is the durable fix once a service is genuinely public.
|
||||
6. **Redirect URIs: prefer `STRICT` over `REGEX`** in Authentik 2026.x.
|
||||
7. **Frontend/backend version skew shows as "Unknown error."** Confirm which Authentik the browser actually loaded (`version` line in the console) before chasing backend logs.
|
||||
8. **WebAuthn/passkeys do not survive host migrations** — delete and re-enroll.
|
||||
|
||||
## Forward-auth outpost — RESOLVED 2026-06-01
|
||||
|
||||
Forward-auth apps (Paperless, qBittorrent, Artifacto) initially still validated against LXC 124's *embedded* outpost (Caddy → `192.168.8.180:9000`) — split-brain against the frozen DB. Pointing Caddy at `https://auth.hubris.network` instead fails: VPS Traefik rewrites `X-Forwarded-Host` → outpost can't match the app → 404 (tested + reverted).
|
||||
|
||||
Fixed with a **dedicated LAN outpost** ([106 — auth-outpost](../../../wiki/containers/106-auth-outpost.md), `192.168.8.6`): `goauthentik/proxy` connects outbound to the VPS core and serves forward-auth locally; Caddy → outpost over the LAN, no Traefik, header preserved. Outpost `hubris-lan-outpost` carries the 3 proxy providers. Verified with 124-Authentik **stopped**. This was Phase 1 of the broader architecture migration (plan: VPS edge / hubris LAN core / Mac Mini redundancy).
|
||||
|
||||
### 2026-06-05 — identification stage skip: broken "Trust me" reputation policy
|
||||
|
||||
The default authentication flow was skipping the identification stage (showing only a password field with `pending_user: ""`), making login impossible. Root cause: a **"Trust me" reputation policy** (`threshold: -3`, `negate: true`) attached to the identification stage binding (#10). The `negate: true` + broken policy evaluation caused the stage binding to fail re-evaluation, and Authentik's `ReevaluateMarker` skipped the stage entirely.
|
||||
|
||||
**Fix:** Deleted from Postgres:
|
||||
```sql
|
||||
DELETE FROM authentik_policies_policybinding WHERE policy_id = '<uuid>';
|
||||
DELETE FROM authentik_policies_reputation_reputationpolicy WHERE policy_ptr_id = '<uuid>';
|
||||
DELETE FROM authentik_policies_policy WHERE policy_uuid = '<uuid>';
|
||||
```
|
||||
The policy was orphaned (no matched type data or had incompatible evaluation). Removing it restores normal two-stage login (identification → password).
|
||||
|
||||
**Note:** This is different from the 2026-05-31 issue where NetBird's `login_hint` OAuth2 param intentionally pre-identifies users. That was expected behavior; this was a genuine bug.
|
||||
|
||||
## Outstanding
|
||||
|
||||
- **NetBird client version skew.** Mac client is `0.68.3` vs mgmt `0.71.3`; NetBird-SSH cert exchange fails between them. Upgrade all clients toward `0.71.x`.
|
||||
- **republic-laptop** regular SSH publickey auth still rejected (correct `authorized_keys`/perms/SELinux context, yet denied — `sshd_config` `AuthorizedKeysFile` suspect). DNS not yet flushed there either.
|
||||
- **muli-laptop** needs `netbird down && netbird up` + `resolvectl flush-caches`.
|
||||
- **VPS port 22** opened for this repair; close once remote access is otherwise stable.
|
||||
- **Decommission LXC 124 Authentik** after a ~2-week dual-run validation. dnsmasq stays on 124 regardless (separate service).
|
||||
- **Reconcile [124-authentik](../../../wiki/containers/106-auth-outpost.md) provider notes** — docs describe a `Public`/PKCE provider; the migrated DB carries the `Confidential` `netbird-dashboard` client. Verify which is live and correct the page.
|
||||
- **sops-encrypt** the VPS secrets (`/opt/authentik.env`) into the `secrets/` tree.
|
||||
|
||||
## Related
|
||||
- [124 — authentik](../../../wiki/containers/106-auth-outpost.md)
|
||||
- [DNS split-horizon](../../../wiki/infrastructure/dns.md)
|
||||
- [Public ingress (VPS traefik)](../../../wiki/infrastructure/ingress.md)
|
||||
- [Mesh migration](../../../wiki/infrastructure/mesh.md)
|
||||
@@ -1,191 +0,0 @@
|
||||
# mac-mini onboarding — post-mortem & lessons learned
|
||||
|
||||
Onboarded the `mac-mini` workstation (macOS Sequoia, arm64) into the hubris
|
||||
homelab context system with the `--with-hermes` profile. What follows is a
|
||||
chronological recap of every hitch, the fix, and the systemic improvements
|
||||
needed so the next workstation takes 5 min instead of an hour.
|
||||
|
||||
## Session log
|
||||
|
||||
### Step 1 — clone + symlink
|
||||
- Manually cloned `git.hubris.network/dtoro/Homelab-Docs` to `/Users/dtoro/Homelab-Docs`.
|
||||
- Created `/opt/homelab-context` → `/Users/dtoro/Homelab-Docs` symlink.
|
||||
- **Lesson:** bootstrap.sh was designed to do this from scratch, but we'd
|
||||
already cloned by hand. The bootstrap's `clone exists; pulling` path handled
|
||||
it gracefully.
|
||||
|
||||
### Step 2 — hostname mismatch
|
||||
- `scutil --get LocalHostName` → `Davids-Mac-mini`
|
||||
- `hostname -s` → `Mac`
|
||||
- Inventory file: `hosts/mac-mini.yaml`
|
||||
- **Fix:** `sudo scutil --set LocalHostName mac-mini && sudo scutil --set HostName mac-mini`
|
||||
- **Lesson:** The bootstrap and `homelab whoami` use different hostname
|
||||
resolution. Bootstrap uses `scutil --get LocalHostName` (correct on macOS),
|
||||
but the `homelab` CLI binary uses `hostname -s`. Both need to match the
|
||||
inventory key. On a fresh macOS machine, neither does.
|
||||
|
||||
### Step 3 — bootstrap dependencies
|
||||
- pyyaml was missing → `pip install pyyaml`
|
||||
- age and sops were missing → `brew install age sops`
|
||||
- Netbird was already installed and connected ✓
|
||||
- **Lesson:** The bootstrap preflight handles these, but only if running
|
||||
`bootstrap.sh` from the start. Since we ran it after manual setup, some
|
||||
steps (netbird install) were correctly skipped as already-present.
|
||||
|
||||
### Step 4 — full bootstrap with `--with-mcp --with-hermes`
|
||||
- Ran `sudo HOMELAB_GITEA_TOKEN=... bash bootstrap.sh --with-mcp --with-hermes`
|
||||
- Age key issued ✓
|
||||
- Launchd sync timer installed ✓
|
||||
- Goose binary installed ✓
|
||||
- Hermes CLI linked ✓
|
||||
- MCP config merged ✓
|
||||
- `refresh-creds` skipped (not yet a recipient) ⚠️
|
||||
- Cosmetics: `chown: dtoro: illegal group name` at the end (benign, macOS
|
||||
group-naming quirk)
|
||||
|
||||
### Step 5 — finalize from hubris
|
||||
- Ran `homelab client add mac-mini --finalize-pubkey <age...> --with-hermes` on
|
||||
hubris
|
||||
- Push failed: `[rejected] main -> main (fetch first)` — hubris clone was
|
||||
stale, bootstrap had already pushed from mac-mini
|
||||
- **Fix:** `git pull --rebase && git push` on hubris
|
||||
- **Lesson:** bootstrap pushes remote changes before hubris can finalize,
|
||||
creating a race. The `homelab client add --finalize-pubkey` command should
|
||||
pull before committing/pushing.
|
||||
|
||||
### Step 6 — sops couldn't find the age key
|
||||
- `homelab secret hello` failed because sops looks in
|
||||
`/Users/dtoro/.ssh/id_rsa` etc. by default, not `/etc/age/key.txt`
|
||||
- The `homelab` CLI re-execs via `sudo -E env SOPS_AGE_KEY_FILE=... sops ...`,
|
||||
but this requires passwordless sudo and the correct env var passthrough
|
||||
- **Fix:**
|
||||
1. Added NOPASSWD sudo rules
|
||||
2. Eventually `SOPS_AGE_KEY` env with the raw key content worked directly
|
||||
- **Lesson:** Document the explicit `SOPS_AGE_KEY_FILE` incantation in
|
||||
agent-enrollment troubleshooting. New clients can't assume `homelab secret`
|
||||
works out of the gate — the sudo re-exec chain has permission pitfall.
|
||||
|
||||
### Step 7 — OpenRouter key was a placeholder
|
||||
- `secrets/openrouter-api-key.yaml` contained
|
||||
`api_key: PLACEHOLDER_REPLACE_WITH_REAL_OPENROUTER_KEY`
|
||||
- User ran `sops` on hubris, but got the same error (age key not found on
|
||||
hubris either — `/root/.config/sops/age/keys.txt` didn't exist)
|
||||
- **Fix:** `SOPS_AGE_KEY_FILE=/etc/age/key.txt sops ...` on hubris.
|
||||
Later: the user pasted the real key, but the sops file showed
|
||||
`sk-or-...5c55` — the literal content was truncated with ellipsis.
|
||||
|
||||
### Step 8 — editor loaded the wrong data
|
||||
- Neovim on the system is configured with `clipboard+=unnamedplus`, which
|
||||
points `*` and `+` registers to the macOS clipboard manager rather than
|
||||
X11. When editing SOPS files, this caused the **system clipboard** to be
|
||||
pasted instead of the actual ciphertext.
|
||||
- This wasn't diagnosed during the session — the sops file would load empty
|
||||
or show the wrong content because the editor's idea of "paste" was
|
||||
disconnected from what sops expected.
|
||||
- **Fix:** Run `sops` with `EDITOR=nano` or another editor that doesn't
|
||||
hijack OS clipboards:
|
||||
```bash
|
||||
EDITOR=nano SOPS_AGE_KEY_FILE=/etc/age/key.txt sops secrets/openrouter-api-key.yaml
|
||||
```
|
||||
- **Lesson:** Add a strong warning to `hermes-agent.md` / `agent-enrollment.md`:
|
||||
macOS neovim with `clipboard+=unnamedplus` silently breaks sops editing
|
||||
because the paste register reads from the system clipboard instead of the
|
||||
sops-managed buffer. Use `EDITOR=nano` or `EDITOR=vim` when running sops
|
||||
interactively. Alternatively, override the clipboard option with
|
||||
`EDITOR='nvim -c "set clipboard="'`.
|
||||
- Also useful for the troubleshooting table in `agent-enrollment.md` under a
|
||||
new row: "sops file loads empty / wrong content on macOS"
|
||||
|
||||
### Step 9 — model doesn't support tool use
|
||||
- Goose config pinned `nousresearch/hermes-4-405b` via OpenRouter
|
||||
- Error: `No endpoints found that support tool use`
|
||||
- **Fix:** Switched to `deepseek/deepseek-v4-flash` in
|
||||
`~/.config/goose/config.yaml`
|
||||
- Also updated `operations/hermes-agent.md` with the correct model
|
||||
- **Lesson:** The default model in `bootstrap.sh` and `hermes-agent.md` was
|
||||
never validated on OpenRouter for tool-use capability. Need to either:
|
||||
(a) Pin a model known to work (`deepseek/deepseek-v4-flash`)
|
||||
(b) Or have the bootstrap probe OpenRouter at install time
|
||||
|
||||
### Step 10 — MCP homelab extension returns 404
|
||||
- Goose's homelab extension configured as `streamable_http` at
|
||||
`https://mcp.hubris.network/mcp` returns HTTP 404
|
||||
- The actual MCP server runs on SSE (`/sse`), not streamable_http
|
||||
- **Known follow-up** from `hermes-agent.md` — server needs migration
|
||||
- **Workaround:** The agent works fine via the built-in `developer` extension
|
||||
(shell + file) and the `homelab` CLI
|
||||
|
||||
### Step 11 — git credential dance
|
||||
Several layers of issues with git authentication:
|
||||
|
||||
1. **Directory permissions:** `/etc/homelab-context` was `0700 root` — dtoro
|
||||
couldn't access the credential file, and git's `store` helper couldn't
|
||||
create its `.lock` file.
|
||||
- **Fix 1a:** `sudo chmod 755 /etc/homelab-context`
|
||||
- **Fix 1b:** `sudo chown -R dtoro:staff /etc/homelab-context`
|
||||
|
||||
2. **macOS built-in osxkeychain:** Apple's git has `osxkeychain` compiled in
|
||||
as a default credential helper. Even after configuring the `store` helper,
|
||||
git calls osxkeychain after every successful `get`, which prompts for
|
||||
macOS keychain access.
|
||||
- `credential.helper = ""` does NOT disable the compiled-in default
|
||||
- The `store` helper's `.lock` file in `/etc/homelab-context/` also failed
|
||||
silently, causing fallback behaviour
|
||||
- **Fix:** Embed the credential in the remote URL directly:
|
||||
`https://dtoro:TOKEN@git.hubris.network/dtoro/Homelab-Docs.git`
|
||||
- This bypasses all credential helpers entirely
|
||||
|
||||
3. **refresh-creds:** The credential is now managed by the remote URL.
|
||||
If the PAT is rotated, `homelab refresh-creds` won't update the URL.
|
||||
- **Workaround:** Run `git remote set-url origin` after `refresh-creds`
|
||||
|
||||
### Step 12 — `homelab` CLI hostname detection
|
||||
- `homelab whoami` failed with `no hosts/Mac.yaml` because the CLI uses
|
||||
`hostname -s` (returning `Mac`) while the bootstrap uses
|
||||
`scutil --get LocalHostName` (which we fixed to `mac-mini`)
|
||||
- **Fix:** Set both `LocalHostName` and `HostName` via `scutil`
|
||||
- **Lesson:** The homelab CLI on macOS should prefer `scutil --get
|
||||
LocalHostName` like the bootstrap does, or at least try both and pick the
|
||||
one that matches an inventory key. This is a CLI bug.
|
||||
|
||||
## Improvement backlog
|
||||
|
||||
### High priority
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|-------|-----|
|
||||
| 1 | Homelab CLI uses `hostname -s` on macOS; should use `scutil --get LocalHostName` to match bootstrap | Patch `bin/homelab` to try `scutil` first, fall back to `hostname -s` |
|
||||
| 2 | `homelab client add --finalize-pubkey` on hubris fails to push when remote is ahead (bootstrap pushes first) | `pull --rebase` before committing in `cmd_client_add` |
|
||||
| 3 | Default Goose model (`nousresearch/hermes-4-405b`) doesn't support tool use on OpenRouter | Update `bootstrap.sh` default and `hermes-agent.md` to `deepseek/deepseek-v4-flash` |
|
||||
| 4 | MCP server needs streamable_http migration | Follow-up #1 from `hermes-agent.md` — FastMCP `transport="sse"` → `transport="streamable_http"` |
|
||||
| 5 | `homelab refresh-creds` should also update embedded remote URLs | In `cmd_refresh_creds`, after writing the credential file, update any remote URL that has an embedded credential |
|
||||
|
||||
### Medium priority
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|-------|-----|
|
||||
| 6 | macOS neovim with `clipboard+=unnamedplus` breaks sops editing | Add troubleshooting row to `agent-enrollment.md`; recommend `EDITOR=nano` |
|
||||
| 7 | `/etc/homelab-context` directory with 0700 root permissions blocks non-root git | Change to 755 in `bootstrap.sh` on all OSes (or at least macOS) |
|
||||
| 8 | Bootstrap `chown` at end gives `illegal group name` on macOS | Fix group detection in bootstrap.sh for macOS |
|
||||
| 9 | `homelab secret` re-exec via sudo chain is fragile — needs passwordless sudo or TTY | Consider `SOPS_AGE_KEY` env-var fallback path in the CLI itself |
|
||||
|
||||
### Low priority
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|-------|-----|
|
||||
| 10 | Onboarding this machine revealed no `HERMES.md` file in the repo | Create the persona file for the Goose hints symlink |
|
||||
| 11 | Git credential `store` helper lock mechanism is OS-sensitive and fragile | Document embedded-URL pattern as the preferred approach for workstations |
|
||||
| 12 | AGENTS.md references `/opt/homelab-context/hosts/<hostname>.yaml` but the canonical path is now a symlink | Verify the path resolution edge cases |
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-01 — initial post-mortem
|
||||
Captured the full onboarding of mac-mini into the hubris homelab context
|
||||
system, including the hostname mismatch, sudo/sops credential chain, model
|
||||
selection, git credential gymnastics, and editor clipboard interference.
|
||||
|
||||
### 2026-06-02 — MCP server migrated to streamable-http
|
||||
Item #4 resolved. FastMCP server now uses `transport="streamable-http"` (hyphen,
|
||||
not underscore), serving at `https://mcp.hubris.network/mcp`. All doc references
|
||||
and client configs (Goose, bootstrap.sh, agent-enrollment.md) updated. Service
|
||||
re-deployed on apps LXC (105).
|
||||
@@ -1,84 +0,0 @@
|
||||
# 2026-06-03 — Moonlight/Sunshine game streaming unstable over WiFi
|
||||
|
||||
## Summary
|
||||
|
||||
[`ludo-mini`](../../../hosts/strong.yaml) runs Sunshine as the game-streaming server; [`mac-mini`](../../../hosts/mac-mini.yaml) runs Moonlight as the client. Despite both machines being on the same physical subnet (192.168.178.0/24), streaming was unstable — stuttering, dropouts, and high latency. Root cause: **mac-mini is connected only via WiFi**, while ludo-mini is wired Ethernet (2.5 Gbps). WiFi throughput shows 1-second UDP dropouts and high jitter (28 ms stddev), which breaks real-time video streaming.
|
||||
|
||||
## Timeline
|
||||
|
||||
### Pre-existing state
|
||||
- ludo-mini: wired Ethernet (eno1, 2.5 Gbps), 192.168.178.181/24
|
||||
- mac-mini: WiFi (en1, 802.11ac, 234 Mbps Tx rate, 1 stream, -60 dBm RSSI), 192.168.178.99/24
|
||||
- Both on the same subnet via a consumer router at 192.168.178.1
|
||||
- Sunshine configured: bitrate=80 Mbps, encoder=amf (AMD GPU), fec_percentage=5, hevc_mode=2
|
||||
- Moonlight configured: bitrate=57 Mbps, fps=60, localaddr=192.168.178.181 (correct)
|
||||
|
||||
### 2026-06-03 — investigation
|
||||
**Network tests (iperf3 between mac-mini ↔ ludo-mini):**
|
||||
- TCP throughput: 49–86 Mbps, average ~65 Mbps — highly variable
|
||||
- UDP throughput: 1-second complete dropout during a 5-second test; 0% loss otherwise
|
||||
- Ping: 4.7 ms avg, but **28 ms stddev**, max **138 ms** — WiFi-level jitter
|
||||
- Netbird P2P connection also showed 7 ms latency over the tunnel (unnecessary given same-subnet direct connection)
|
||||
|
||||
**Additional findings:**
|
||||
- mac-mini has a built-in Ethernet port (en0) but it is inactive — no cable connected
|
||||
- mac-mini also has Thunderbolt Ethernet adapters (en5, en6, en7) all inactive
|
||||
- ludo-mini's en0 shows `speed 2500` (2.5 GbE)
|
||||
- Netbird routes `192.168.8.0/24` via `utun100`, but 192.168.178.0/24 traffic stays on `en1`
|
||||
|
||||
## Root cause
|
||||
|
||||
mac-mini is on WiFi. WiFi introduces:
|
||||
1. **Throughput variance** — 49–86 Mbps TCP fluctuation
|
||||
2. **Dropouts** — 1-second UDP blackouts from radio retransmissions
|
||||
3. **Jitter** — 28 ms stddev with spikes to 138 ms
|
||||
|
||||
Moonlight/Sunshine streams real-time video over UDP. Any jitter spike or dropout causes visual stutter or frame drops. The 57 Mbps Moonlight bitrate was too aggressive for the WiFi link's consistency, even though the average throughput is ~65 Mbps.
|
||||
|
||||
## Mitigations applied
|
||||
|
||||
### mac-mini — Moonlight (via `defaults write`)
|
||||
|
||||
| Setting | Before | After | Rationale |
|
||||
||---------|--------|-------|-----------|
|
||||
|| bitrate | 57 Mbps | 25 Mbps | Reduce to a level WiFi can sustain consistently |
|
||||
|| framepacing | off (0) | on (1) | Smooths frame timing over variable latency |
|
||||
|| fps | 30 | 60 | Restored to game-appropriate value |
|
||||
|
||||
### mac-mini — Moonlight (current, wired — 2026-06-04)
|
||||
|
||||
| Setting | WiFi value | Wired value | Rationale |
|
||||
||---------|-----------|-------------|-----------|
|
||||
|| bitrate | 60 Mbps | 80 Mbps | Wired 1 GbE can sustain comfortably |
|
||||
|| framepacing | on (1) | off (0) | Wired latency is consistent, no smoothing needed |
|
||||
|| fps | 60 | 60 | Unchanged |
|
||||
|
||||
### ludo-mini — Sunshine (via SSH)
|
||||
|
||||
| Setting | WiFi value | Wired value | Rationale |
|
||||
||---------|-----------|-------------|-----------|
|
||||
|| bitrate | 40 Mbps | 80 Mbps | Wired can handle full bandwidth |
|
||||
|| fec_percentage | 2 | 5 | Restored to default — wired has no dropout concern |
|
||||
|| packetsize | 1024 | 1316 | Restored to default (Ethernet MTU) |
|
||||
|
||||
Sunshine restarted after changes; confirmed active.
|
||||
|
||||
## Resolution
|
||||
|
||||
### 2026-06-04 — mac-mini wired to LAN
|
||||
|
||||
mac-mini plugged into Ethernet (en0, 192.168.178.182). This definitively resolves the WiFi jitter that caused streaming instability. All WiFi-era conservative settings reverted to appropriate wired values on both ends.
|
||||
|
||||
## Open questions
|
||||
|
||||
~~1. **Ethernet wiring** — mac-mini has an active en0 port. Plugging it into the same switch/router as ludo-mini would eliminate WiFi jitter entirely. This is the definitive fix.~~ **RESOLVED** — mac-mini now wired via en0.
|
||||
2. **WiFi alternatives** — If wiring is impractical, a WiFi 6 (802.11ax) USB adapter or Thunderbolt-to-Ethernet adapter would help, but wired Ethernet is the only reliable solution for game streaming.
|
||||
3. **Netbird route** — `192.168.8.0/24` is routed over Netbird `utun100`. If Moonlight ever falls back to that subnet (ludo-mini's 192.168.8.133 LAN IP), traffic tunnels unnecessarily. Moonlight correctly uses 192.168.178.181, so this is not currently an issue.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-04 — mac-mini wired to LAN, configs reverted from WiFi mitigations
|
||||
- mac-mini connected via en0 (wired Ethernet, 192.168.178.182), WiFi mitigations no longer needed
|
||||
- Moonlight: bitrate 60→80 Mbps, framepacing disabled
|
||||
- Sunshine: bitrate 40→80 Mbps, fec_percentage 2→5, packetsize restored to default (1316)
|
||||
- First root cause in investigation (Open questions #1) resolved
|
||||
@@ -1,101 +0,0 @@
|
||||
# 2026-06-06 — Frequent Authentik login prompts fixed (session duration)
|
||||
|
||||
## Summary
|
||||
|
||||
User needed to re-authenticate to Authentik several times per day. Root cause was the Django session being configured as a **session cookie** (cleared on browser close) with a short 24-hour lifetime. Fixed by extending both the session cookie lifetime and the user_login stage session duration to 30 days.
|
||||
|
||||
## Timeline
|
||||
|
||||
- **2026-06-06 ~23:00** — User reports "having to login to authentik several times per day"
|
||||
- **~23:10** — Investigation begins: check Authentik OAuth2 provider settings, proxy session table, Django session config
|
||||
- **~23:20** — Found `SESSION_EXPIRE_AT_BROWSER_CLOSE=True` — the `authentik_session` cookie has no Max-Age, cleared on browser close
|
||||
- **~23:25** — Found the `user_login` stage has `session_duration=seconds=0`, which calls `session.set_expiry(0)` → `_session_expire_at_browser_close=True`
|
||||
- **~23:30** — Applied DB fix: updated `authentik_stages_user_login_userloginstage` → `session_duration='days=30'`
|
||||
- **~23:35** — Added `AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE=days=30` to `/opt/authentik.env` (maps to `sessions.unauthenticated_age` in Authentik config, setting `SESSION_COOKIE_AGE` to 30 days)
|
||||
- **~23:40** — Recreated container with `docker compose up -d` (restart doesn't re-read env file)
|
||||
- **~23:45** — Verified both changes took effect
|
||||
|
||||
## Root cause
|
||||
|
||||
Two independent but compounding issues:
|
||||
|
||||
1. **`SESSION_EXPIRE_AT_BROWSER_CLOSE=True`** — hardcoded in `/authentik/root/settings.py`. Makes the `authentik_session` cookie a session cookie (no `Max-Age`), cleared when the browser closes. Cannot be changed via env vars or config files (hardcoded in Python source).
|
||||
|
||||
2. **`user_login` stage `session_duration=seconds=0`** — the default-authentication-login stage called `session.set_expiry(0)`, which in Django sets `_session_expire_at_browser_close=True` on the session object, overriding any session cookie lifetime set via `SESSION_COOKIE_AGE`.
|
||||
|
||||
3. **`sessions.unauthenticated_age=days=1`** (default) — `SESSION_COOKIE_AGE` was 86,400 seconds (24 hours), so even with the browser left open, the server-side session data expired after 24 hours with no way to extend it (`SESSION_SAVE_EVERY_REQUEST=False`).
|
||||
|
||||
### Why forward-auth worked but OAuth2 didn't
|
||||
|
||||
- **Forward-auth (Caddy-gated services)** — The `authentik_proxy_*` cookie is a *persistent* cookie with 24-hour validity, set by the Authentik proxy outpost. It survives browser restart. Caddy's forward-auth validates this cookie directly with the outpost API — no Django session needed.
|
||||
- **OAuth2 (Gitea, Jellyfin, etc.)** — These services redirect to `auth.hubris.network/application/o/authorize/`, which checks the Django session (`authentik_session` cookie). If the browser was closed, this cookie is gone → user is redirected to the login form.
|
||||
|
||||
## Changes applied
|
||||
|
||||
### 1. Database — `authentik_stages_user_login_userloginstage`
|
||||
|
||||
```sql
|
||||
UPDATE authentik_stages_user_login_userloginstage
|
||||
SET session_duration = 'days=30'
|
||||
WHERE stage_ptr_id = (
|
||||
SELECT stage_uuid FROM authentik_flows_stage
|
||||
WHERE name = 'default-authentication-login'
|
||||
);
|
||||
```
|
||||
|
||||
This causes the login stage to call `session.set_expiry(timedelta(days=30))`, which:
|
||||
- Sets `_session_expire_at_browser_close=False` for that session
|
||||
- Sets the session cookie `Max-Age` to 30 days (persistent cookie)
|
||||
- Sets the server-side session expiry to 30 days
|
||||
|
||||
### 2. Environment — `AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE`
|
||||
|
||||
Added to `/opt/authentik.env`:
|
||||
|
||||
```
|
||||
AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE=days=30
|
||||
```
|
||||
|
||||
This maps to config key `sessions.unauthenticated_age`, which Authentik's `settings.py` reads via:
|
||||
|
||||
```python
|
||||
SESSION_COOKIE_AGE = timedelta_from_string(
|
||||
CONFIG.get("sessions.unauthenticated_age", "days=1")
|
||||
).total_seconds()
|
||||
```
|
||||
|
||||
Result: `SESSION_COOKIE_AGE` = 2,592,000 seconds (30 days). This is a fallback for sessions that don't go through the `user_login` stage or when the stage's explicit `set_expiry` doesn't apply.
|
||||
|
||||
## Verification
|
||||
|
||||
```python
|
||||
# Inside authentik-server container:
|
||||
import os
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
|
||||
import django
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
print("SESSION_COOKIE_AGE:", settings.SESSION_COOKIE_AGE) # → 2592000.0
|
||||
print("SESSION_EXPIRE_AT_BROWSER_CLOSE:", settings.SESSION_EXPIRE_AT_BROWSER_CLOSE) # → True
|
||||
|
||||
from authentik.stages.user_login.models import UserLoginStage
|
||||
stage = UserLoginStage.objects.filter(name="default-authentication-login").first()
|
||||
print("session_duration:", stage.session_duration) # → "days=30"
|
||||
```
|
||||
|
||||
## Open questions
|
||||
|
||||
- `SESSION_COOKIE_SECURE=False` — Should be `True` since Authentik is HTTPS-only, but the custom `SessionMiddleware.is_secure()` method dynamically sets the cookie's `secure` flag based on the request, so it's fine.
|
||||
- 30 days is generous. Could be reduced to 7-14 days if desired. Change the DB value and env var accordingly.
|
||||
|
||||
## Related
|
||||
|
||||
- [Container 106 — auth-outpost](../../wiki/containers/106-auth-outpost.md)
|
||||
- [Authentik VPS migration](archive/2026-05-31-authentik-vps-migration.md)
|
||||
- [Ingress (VPS Traefik)](../../wiki/infrastructure/ingress.md)
|
||||
- `.hermes/plans/2026-06-06_232200-authentik-frequent-login-fix.md` — original plan
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-06 — created
|
||||
Documented the session lifetime investigation, root cause, and applied fixes.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Investigation: Caddyfile truncation — all LAN services down (2026-06-06)
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** resolved
|
||||
**Duration:** ~10 hours (from last known good state ~12:39 UTC to restoration ~22:40 UTC)
|
||||
|
||||
## Symptom
|
||||
|
||||
All `*.hubris.network` URLs except `photos.hubris.network` and `auth.hubris.network` returned `tlsv1 alert internal error` or TCP timeouts from LAN/mesh clients. `dig @192.168.8.2` and `dig @100.122.255.254` both resolved to `192.168.8.175` correctly — DNS was fine. The issue was at the Caddy level.
|
||||
|
||||
## Root cause
|
||||
|
||||
The Caddyfile on LXC 121 was manually edited directly on the filesystem (not via the `dtoro/caddy-conf` git repo), reducing it from 260 lines/30+ site blocks to 43 lines with only 3 photo-related site blocks: `photos.hubris.network`, `prism.hubris.network`, and `photos2.hubris.network`.
|
||||
|
||||
### Timeline
|
||||
|
||||
| Time (UTC+2) | Event |
|
||||
|---|---|
|
||||
| Jun 04 23:43 | Last successful git-push deploy — full Caddyfile (260 lines) |
|
||||
| Jun 06 ~12:00 | Caddyfile manually edited locally, truncating to 3 sites |
|
||||
| Jun 06 12:39 | Deploy webhook triggered → `git pull --ff-only` failed: "Your local changes would be overwritten" |
|
||||
| Jun 06 14:13 | Deploy webhook triggered again → `deploy ok` (the truncated file was committed or merged somehow) |
|
||||
| Jun 06 22:34 | Investigation began |
|
||||
| Jun 06 22:43 | Caddyfile restored from `origin/master`, `systemctl reload caddy` |
|
||||
|
||||
### Evidence
|
||||
|
||||
- `git diff HEAD -- Caddyfile` on LXC 121: `+3 / -159` lines
|
||||
- Git reflog: HEAD at `32575ce` (fix: sab port 8081→8082), working tree diverged
|
||||
- Backup file `Caddyfile.bak.1780263919`: 225 lines, full original config
|
||||
- `git stash list` shows one auto-stash entry
|
||||
- `origin/master` at `1b977aa`: 260 lines, all site blocks present
|
||||
|
||||
### Secondary root cause found during investigation
|
||||
|
||||
**elementsynapse (LXC 118)** had `iface eth0 inet dhcp` internally despite `pct set 118 --net0 ... ip=192.168.8.239/24`. On DHCP lease renewal, dhclient grabbed `.244` from Technitium's pool. Caddy's `reverse_proxy 192.168.8.239:8008` was hitting a dead IP.
|
||||
|
||||
This is the same class of drift as the June 5th incidents (paperless, HAOS, apps, mule-images). Elementsynapse was missed during the 2026-06-02 static-IP migration.
|
||||
|
||||
## Fix applied
|
||||
|
||||
1. **Caddyfile** → `git checkout --force origin/master -- Caddyfile` + `systemctl reload caddy`
|
||||
2. **elementsynapse** → replaced `iface eth0 inet dhcp` with static, killed dhclient, verified connectivity
|
||||
|
||||
## Permanent safeguards (all deployed)
|
||||
|
||||
| Safeguard | Location | What it does |
|
||||
|---|---|---|
|
||||
| Site-count guard | `/etc/caddy/scripts/deploy.sh` | Refuses reload if <20 `hubris.network` site blocks |
|
||||
| Dirty-tree auto-stash | `/etc/caddy/scripts/deploy.sh` | Stashes local edits before git pull |
|
||||
| Auto-backup | `/etc/caddy/scripts/deploy.sh` | Saves Caddyfile.bak.<timestamp> before any change, keeps 5 |
|
||||
| Caddy backend health | `/etc/cron.d/caddy-backend-health` on hubris | Runs `check-caddy-backends.sh` every 10 min |
|
||||
| DNS sync | `/etc/cron.d/dns-sync` on LXC 107 | Runs `dns-sync.py` every 10 min (was missing since 2026-06-04) |
|
||||
|
||||
## Related
|
||||
|
||||
- DHCP drift investigation (previous incident) — not filed as its own investigation; see the [DNS sync fix](../../../.hermes/plans/2026-06-05_170000-prevent-dhcp-ip-drift.md)
|
||||
- [Caddy (121)](../../wiki/containers/121-caddy.md)
|
||||
- [elementsynapse (118)](../../wiki/containers/118-elementsynapse.md)
|
||||
- [dns-sync script](../../../scripts/dns-sync.py)
|
||||
- [check-caddy-backends script](../../../scripts/check-caddy-backends.sh)
|
||||
@@ -1,29 +0,0 @@
|
||||
# Investigations
|
||||
|
||||
Time-stamped incident reports and experiments. One entry per incident; the entry is the canonical source. Per-node changelog entries link back here.
|
||||
|
||||
## Active / recent
|
||||
|
||||
| Date | Title | Status |
|
||||
| ------------ | ------------------------------------------------------------------ | ------------- |
|
||||
| 2026-06-06 | [Caddyfile truncation incident](2026-06-06-caddyfile-truncation.md) | Resolved — permanent safeguards deployed (site-count guard, auto-stash, auto-backup) |
|
||||
| 2026-06-06 | [Frequent Authentik login prompts — session lifetime fix](2026-06-06-authentik-session-lifetime.md) | Resolved — `session_duration=days=30`, `SESSION_COOKIE_AGE=30d` |
|
||||
| 2026-06-03 | [Moonlight/Sunshine streaming — WiFi jitter](2026-06-03-moonlight-sunshine-wifi-jitter.md) | Mitigations applied; definitive fix requires wiring mac-mini via Ethernet |
|
||||
| 2026-06-01 | [Mac-mini onboarding](2026-06-01-mac-mini-onboarding.md) | Onboarded |
|
||||
|
||||
## Resolved (archived)
|
||||
|
||||
See [`archive/`](archive/):
|
||||
|
||||
| Date | Title |
|
||||
| ------------ | ------------------------------------------------------------------ |
|
||||
| 2026-04-21 | [Hubris crash loop — thermal + USB drive](archive/2026-04-21-hubris-crash-loop.md) |
|
||||
| 2026-05-31 | [Authentik migrated from LXC 124 to the VPS](archive/2026-05-31-authentik-vps-migration.md) |
|
||||
|
||||
## Conventions
|
||||
|
||||
- File name: `YYYY-MM-DD-<slug>.md`. Use the *first* date if the incident spans multiple days.
|
||||
- Mandatory sections: Summary, Timeline, Root cause, Mitigations applied, Open questions.
|
||||
- Update the entry as the situation evolves; never rewrite history. Add new dated sections at the bottom.
|
||||
- Link back from every node's changelog that's affected.
|
||||
- Move to `archive/` when the incident is fully resolved and no longer actively referenced.
|
||||
@@ -1,10 +0,0 @@
|
||||
# Knowledge — operations log
|
||||
|
||||
Append-only record of documentation-maintenance operations on the knowledge wiki (restructures,
|
||||
source ingests, lint sweeps). One line per operation, newest last. Infrastructure changes belong in
|
||||
each page's `## Changelog` and the Oikos change ledger, not here.
|
||||
|
||||
## [2026-07-06] restructure | moved node/infrastructure narratives under knowledge/wiki/; references under knowledge/sources/; repointed inventory doc_page fields and gen-topology.py output.
|
||||
## [2026-07-06] lint | banned-vocabulary scan of knowledge/ clean; added .agents/skills/docs-lint and knowledge/wiki/hosts/index.md.
|
||||
## [2026-07-06] lint | fixed 126 pre-existing broken links (124-authentik.md rename, investigations/plans moved to archive/done, archive/ sibling depth, destroyed-node delinks); 2 remaining are an intentional cross-repo reference.
|
||||
## [2026-07-06] restructure | Phase 6 consolidation: investigations/ -> knowledge/sources/investigations/; operations/ -> .agents/operations/; HERMES.md -> .agents/; deleted root OIKOS/CAVEMAN/CONTRIBUTING stubs.
|
||||
@@ -1,351 +0,0 @@
|
||||
# Current cert sync script + traefik dynamic config
|
||||
|
||||
Snapshot of the two artifacts that control public service exposure as of
|
||||
2026-07-05. Updated 2026-07-05: fixed Jellyfin backend from dead hubris IP
|
||||
(192.168.8.206) to new strong IP (192.168.8.246).
|
||||
|
||||
## hubris-public-cert-sync.sh (PVE host, `/usr/local/bin/`)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Mirrors home caddy's LE certs for publicly-exposed hubris.network hostnames
|
||||
# into the VPS traefik's /letsencrypt volume. Traefik file-watches the volume
|
||||
# and hot-reloads.
|
||||
#
|
||||
# Why: netbird-proxy's HostSNI(*) TCP passthrough intercepts ACME TLS-ALPN-01
|
||||
# challenges before traefik's allowACMEByPass can respond, so traefik can't
|
||||
# obtain its own cert. Home caddy uses IONOS DNS-01 (no such conflict);
|
||||
# we just mirror what it already has.
|
||||
#
|
||||
# Runs daily via hubris-public-cert-sync.timer.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CADDY_LXC=121
|
||||
CADDY_BASE=/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory
|
||||
VPS_HOST=root@100.122.165.149
|
||||
VPS_DEST=/var/lib/docker/volumes/opt_netbird_traefik_letsencrypt/_data
|
||||
|
||||
# Map: source hostname -> "crt_filename key_filename" on the VPS.
|
||||
# Stable names so traefik dynamic.yaml doesn't need edits on renewal.
|
||||
declare -A HOSTS=(
|
||||
[artifacto.hubris.network]="fullchain.crt privkey.key"
|
||||
[blog.hubris.network]="blog.fullchain.crt blog.privkey.key"
|
||||
[trmnl.hubris.network]="trmnl.fullchain.crt trmnl.privkey.key"
|
||||
[sso.hubris.network]="sso.fullchain.crt sso.privkey.key"
|
||||
[media.hubris.network]="media.fullchain.crt media.privkey.key"
|
||||
[paperless.hubris.network]="paperless.fullchain.crt paperless.privkey.key"
|
||||
)
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
for host in "${!HOSTS[@]}"; do
|
||||
read -r crt_name key_name <<< "${HOSTS[$host]}"
|
||||
pct pull "$CADDY_LXC" "$CADDY_BASE/$host/$host.crt" "$TMP/$crt_name"
|
||||
pct pull "$CADDY_LXC" "$CADDY_BASE/$host/$host.key" "$TMP/$key_name"
|
||||
|
||||
if ssh -o BatchMode=yes "$VPS_HOST" "test -f $VPS_DEST/$crt_name && diff -q - $VPS_DEST/$crt_name" < "$TMP/$crt_name" >/dev/null 2>&1; then
|
||||
echo "hubris-public-cert-sync: $host unchanged"
|
||||
continue
|
||||
fi
|
||||
|
||||
scp -q -o BatchMode=yes "$TMP/$crt_name" "$TMP/$key_name" "$VPS_HOST:$VPS_DEST/"
|
||||
echo "hubris-public-cert-sync: shipped $host ($(openssl x509 -in "$TMP/$crt_name" -noout -enddate))"
|
||||
done
|
||||
```
|
||||
|
||||
### Adding a new host
|
||||
|
||||
1. Caddy must already have the cert (verify `pct exec 121 -- ls "$CADDY_BASE/$host/"`)
|
||||
2. Add a line to the HOSTS array: `[new-host.hubris.network]="nickname.fullchain.crt nickname.privkey.key"`
|
||||
3. `systemctl start hubris-public-cert-sync.service` to sync immediately
|
||||
4. Verify certs landed: `ssh "$VPS_HOST" "ls -la $VPS_DEST/nickname.*"`
|
||||
5. Add matching `tls.certificates` entry in traefik dynamic config
|
||||
|
||||
---
|
||||
|
||||
## traefik-dynamic.yaml (VPS, `/opt/`)
|
||||
|
||||
```yaml
|
||||
tcp:
|
||||
serversTransports:
|
||||
pp-v2:
|
||||
proxyProtocol:
|
||||
version: 2
|
||||
|
||||
tls:
|
||||
certificates:
|
||||
- certFile: /letsencrypt/fullchain.crt
|
||||
keyFile: /letsencrypt/privkey.key
|
||||
- certFile: /letsencrypt/blog.fullchain.crt
|
||||
keyFile: /letsencrypt/blog.privkey.key
|
||||
- certFile: /letsencrypt/trmnl.fullchain.crt
|
||||
keyFile: /letsencrypt/trmnl.privkey.key
|
||||
- certFile: /letsencrypt/sso.fullchain.crt
|
||||
keyFile: /letsencrypt/sso.privkey.key
|
||||
- certFile: /letsencrypt/media.fullchain.crt
|
||||
keyFile: /letsencrypt/media.privkey.key
|
||||
- certFile: /letsencrypt/paperless.fullchain.crt
|
||||
keyFile: /letsencrypt/paperless.privkey.key
|
||||
|
||||
http:
|
||||
routers:
|
||||
artifacto-public:
|
||||
rule: 'Host(`artifacto.hubris.network`) && (PathPrefix(`/p/`) || PathPrefix(`/static/`) || Path(`/healthz`))'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- artifacto-strip-sso
|
||||
- artifacto-ratelimit
|
||||
service: artifacto-public
|
||||
|
||||
blog-public:
|
||||
rule: 'Host(`blog.hubris.network`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- blog-ratelimit
|
||||
service: blog-public
|
||||
|
||||
trmnl-public:
|
||||
rule: 'Host(`trmnl.hubris.network`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- trmnl-ratelimit
|
||||
service: trmnl-public
|
||||
|
||||
matrix-public:
|
||||
rule: 'Host(`matrix.hubris.network`) && !PathPrefix(`/.well-known/matrix/`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- matrix-ratelimit
|
||||
service: matrix-public
|
||||
|
||||
matrix-wellknown:
|
||||
rule: 'Host(`matrix.hubris.network`) && (PathPrefix(`/.well-known/matrix/`) || PathPrefix(`/.well-known/acme-challenge/`))'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 20
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: matrix-wellknown-svc
|
||||
|
||||
house-public:
|
||||
rule: Host(`house.hubris.network`)
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- house-ratelimit
|
||||
service: house-public
|
||||
|
||||
sso-public:
|
||||
rule: 'Host(`sso.hubris.network`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- sso-ratelimit
|
||||
service: sso-public
|
||||
|
||||
media-public:
|
||||
rule: 'Host(`media.hubris.network`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- media-ratelimit
|
||||
service: media-public
|
||||
|
||||
paperless-api-public:
|
||||
rule: 'Host(`paperless.hubris.network`) && PathPrefix(`/api/`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 20
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- paperless-ratelimit
|
||||
service: paperless-public
|
||||
|
||||
paperless-public:
|
||||
rule: 'Host(`paperless.hubris.network`)'
|
||||
entryPoints:
|
||||
- websecure
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- authentik-forwardauth
|
||||
- paperless-ratelimit
|
||||
service: paperless-public
|
||||
|
||||
middlewares:
|
||||
artifacto-strip-sso:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Artifacto-Gateway: ""
|
||||
X-Authentik-Username: ""
|
||||
X-Authentik-Groups: ""
|
||||
X-Authentik-Email: ""
|
||||
X-Authentik-Name: ""
|
||||
X-Authentik-Uid: ""
|
||||
X-Authentik-Jwt: ""
|
||||
X-Authentik-Meta-Jwks: ""
|
||||
X-Authentik-Meta-Outpost: ""
|
||||
X-Authentik-Meta-Provider: ""
|
||||
X-Authentik-Meta-App: ""
|
||||
X-Authentik-Meta-Version: ""
|
||||
|
||||
admin-allowlist:
|
||||
ipAllowList:
|
||||
sourceRange:
|
||||
- "5.61.168.0/24"
|
||||
|
||||
artifacto-ratelimit:
|
||||
rateLimit:
|
||||
average: 50
|
||||
period: 1s
|
||||
burst: 100
|
||||
blog-ratelimit:
|
||||
rateLimit:
|
||||
average: 100
|
||||
period: 1s
|
||||
burst: 200
|
||||
trmnl-ratelimit:
|
||||
rateLimit:
|
||||
average: 20
|
||||
period: 1s
|
||||
burst: 40
|
||||
matrix-ratelimit:
|
||||
rateLimit:
|
||||
average: 30
|
||||
period: 1s
|
||||
burst: 60
|
||||
house-ratelimit:
|
||||
rateLimit:
|
||||
average: 30
|
||||
period: 1s
|
||||
burst: 60
|
||||
sso-ratelimit:
|
||||
rateLimit:
|
||||
average: 30
|
||||
period: 1s
|
||||
burst: 60
|
||||
media-ratelimit:
|
||||
rateLimit:
|
||||
average: 30
|
||||
period: 1s
|
||||
burst: 60
|
||||
paperless-ratelimit:
|
||||
rateLimit:
|
||||
average: 20
|
||||
period: 1s
|
||||
burst: 40
|
||||
authentik-forwardauth:
|
||||
forwardAuth:
|
||||
address: "http://192.168.8.6:9000/outpost.goauthentik.io/auth/traefik"
|
||||
trustForwardHeader: true
|
||||
authResponseHeaders:
|
||||
- X-authentik-username
|
||||
- X-authentik-groups
|
||||
- X-authentik-email
|
||||
- X-authentik-name
|
||||
- X-authentik-uid
|
||||
- X-authentik-jwt
|
||||
- X-authentik-meta-jwks
|
||||
- X-authentik-meta-outpost
|
||||
- X-authentik-meta-provider
|
||||
- X-authentik-meta-app
|
||||
- X-authentik-meta-version
|
||||
|
||||
services:
|
||||
artifacto-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.205:3100'
|
||||
blog-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.205:8080'
|
||||
trmnl-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.211:9851'
|
||||
matrix-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.242:8008'
|
||||
matrix-wellknown-svc:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://matrix-wellknown:80'
|
||||
house-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.244:3000'
|
||||
sso-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.6:9000'
|
||||
media-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.246:8096'
|
||||
paperless-public:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: 'http://192.168.8.130:8000'
|
||||
```
|
||||
|
||||
### Adding a new service — four blocks needed
|
||||
|
||||
1. **Router** — `http.routers.<name>-public` with `tls: {}` (not
|
||||
`certResolver`)
|
||||
2. **Middleware** — rate limit, one per service
|
||||
3. **Service** — `http.services.<name>-public` with the backend URL
|
||||
4. **tls.certificates** — add a new `- certFile/keryFile` pair matching the
|
||||
cert sync HOSTS entry
|
||||
|
||||
### Key file naming convention
|
||||
|
||||
| Cert name | Host | Convention |
|
||||
|-----------|------|------------|
|
||||
| `fullchain.crt` + `privkey.key` | `artifacto.hubris.network` | First service — no prefix |
|
||||
| `blog.fullchain.crt` + `blog.privkey.key` | `blog.hubris.network` | `{nickname}.fullchain.crt` |
|
||||
| `trmnl.fullchain.crt` + `trmnl.privkey.key` | `trmnl.hubris.network` | `{nickname}.fullchain.crt` |
|
||||
| `sso.fullchain.crt` + `sso.privkey.key` | `sso.hubris.network` | `{nickname}.fullchain.crt` |
|
||||
| `media.fullchain.crt` + `media.privkey.key` | `media.hubris.network` | `{nickname}.fullchain.crt` |
|
||||
| `paperless.fullchain.crt` + `paperless.privkey.key` | `paperless.hubris.network` | `{nickname}.fullchain.crt` |
|
||||
|
||||
### ⚠️ Critical — keep backends in sync after LXC migrations
|
||||
|
||||
When moving an LXC between Proxmox nodes, update **both**:
|
||||
1. **Caddy** (`/etc/caddy/Caddyfile` on LXC 121)
|
||||
2. **VPS traefik** (`/opt/traefik-dynamic.yaml` — via hubris bridge SSH)
|
||||
|
||||
Jellyfin migration from hubris to strong (2026-07-05) was fixed in Caddy
|
||||
but **missed** in VPS traefik — old IP `192.168.8.206` remained. This caused
|
||||
Bad Gateway for off-LAN users. Use Python-based editing (see
|
||||
`references/traefik-config-editing.md`) for accurate surgical fixes.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Sources
|
||||
|
||||
Immutable evidence the wiki synthesizes from. External reference docs live under `references/`;
|
||||
incident evidence lives in [`investigations/`](investigations/index.md) (written once at
|
||||
incident time, then linked from the changelogs of the nodes they implicate).
|
||||
|
||||
| Slug | Reference | Summary |
|
||||
|------|-----------|---------|
|
||||
| cert-sync-and-traefik-config | [references/cert-sync-and-traefik-config.md](references/cert-sync-and-traefik-config.md) | VPS traefik config and the LAN↔VPS certificate mirror. |
|
||||
@@ -1,14 +0,0 @@
|
||||
# VMs — index
|
||||
|
||||
Two QEMU VMs running on [hubris](../hosts/hubris.md):
|
||||
|
||||
| ID | Name | Role | IP | Public hostname |
|
||||
|----|------|------|----|-----------------|
|
||||
| 100 | [zimaos](100-zimaos.md) | NAS frontend eval (ZimaOS) | `192.168.8.195` | [`zimaos.hubris.network`](../infrastructure/dns.md) |
|
||||
| 108 | [haos-16.3](108-haos.md) | Home automation (HAOS) | `192.168.8.101` | [`home.hubris.network`](../infrastructure/dns.md) |
|
||||
|
||||
## Related
|
||||
|
||||
- [Hubris host](../hosts/hubris.md) — both VMs run here
|
||||
- [Containers index](../containers/index.md) — LXCs on both nodes
|
||||
- [README](../../../README.md)
|
||||
@@ -1,5 +0,0 @@
|
||||
{"ts": "2026-07-06T11:05:35+00:00", "agent": "mac-mini", "entity": "host:teddycloud", "action": "activate", "risk": "config_mutation", "verification": "homelab node teddycloud relations", "result": "ok"}
|
||||
{"ts": "2026-07-06T11:15:04+00:00", "agent": "mac-mini", "entity": "repo:Homelab-Docs", "action": "register-webhook", "risk": "config_mutation", "result": "ok", "notes": "webhook id 14 for oikos-console deploy"}
|
||||
{"ts": "2026-07-06T11:29:56+00:00", "agent": "mac-mini", "entity": "service:caddy", "action": "add-site-block", "risk": "config_mutation", "verification": "curl -s https://git.hubris.network (unrelated route still healthy after reload)", "result": "ok", "notes": "oikos.hubris.network -> 192.168.8.205:8091, Authentik-gated, in dtoro/caddy-conf@c195142"}
|
||||
{"ts": "2026-07-06T11:40:28+00:00", "agent": "mac-mini", "entity": "host:dns", "action": "add-record", "risk": "config_mutation", "verification": "dig @192.168.8.2 +short oikos.hubris.network", "result": "ok", "notes": "oikos.hubris.network A -> 192.168.8.175 (Caddy LAN IP), via Technitium API, no token persisted"}
|
||||
{"ts": "2026-07-06T11:57:21+00:00", "agent": "mac-mini", "entity": "host:apps", "action": "deploy-oikos-console", "risk": "config_mutation", "verification": "curl http://127.0.0.1:8091/ on apps -> 200; https://oikos.hubris.network/ -> 302 (Authentik gate)", "result": "ok"}
|
||||
@@ -1,21 +0,0 @@
|
||||
# apps (host:apps)
|
||||
|
||||
- kind: lxc (LXC 105)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: docker-apps
|
||||
- address: 192.168.8.205 (mesh: tailscale:apps)
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/105-apps.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:artifacto, service:homelab_mcp, service:secrets_issuance
|
||||
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/Artifacto, repo:dtoro/Homelab-Docs
|
||||
- full blast radius: service:artifacto, service:homelab_mcp, service:secrets_issuance
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- 2026-07-06T11:57:21+00:00 deploy-oikos-console (config_mutation) — ok
|
||||
@@ -1,20 +0,0 @@
|
||||
# arriman (host:arriman)
|
||||
|
||||
- kind: lxc (LXC 122)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: arr-stack
|
||||
- address: 192.168.8.245 (mesh: tailscale:arr)
|
||||
- mounts: /mnt/media_local
|
||||
- doc: knowledge/wiki/containers/122-arriman.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:arr_stack
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
- full blast radius: service:arr_stack
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,18 +0,0 @@
|
||||
# auth-outpost (host:auth-outpost)
|
||||
|
||||
- kind: lxc (LXC 106)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: authentik-gateway
|
||||
- address: 192.168.8.6
|
||||
- doc: knowledge/wiki/containers/106-auth-outpost.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:hubris
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# caddy (host:caddy)
|
||||
|
||||
- kind: lxc (LXC 121)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: reverse-proxy
|
||||
- address: 192.168.8.175
|
||||
- doc: knowledge/wiki/containers/121-caddy.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:caddy
|
||||
- affected by: host:hubris, repo:dtoro/caddy-conf
|
||||
- full blast radius: service:caddy
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# dns (host:dns)
|
||||
|
||||
- kind: lxc (LXC 107)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: dns-server
|
||||
- address: 192.168.8.2
|
||||
- doc: knowledge/wiki/containers/107-dns.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:dns
|
||||
- affected by: host:hubris
|
||||
- full blast radius: service:dns
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- 2026-07-06T11:40:28+00:00 add-record (config_mutation) — ok
|
||||
@@ -1,19 +0,0 @@
|
||||
# elementsynapse (host:elementsynapse)
|
||||
|
||||
- kind: lxc (LXC 118)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: matrix-server
|
||||
- address: 192.168.8.242
|
||||
- doc: knowledge/wiki/containers/118-elementsynapse.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:matrix
|
||||
- affected by: host:strong
|
||||
- full blast radius: service:matrix
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# gitea (host:gitea)
|
||||
|
||||
- kind: lxc (LXC 104)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: git-server
|
||||
- address: 192.168.8.121 (mesh: tailscale:gitea)
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/104-gitea.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:gitea
|
||||
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/gitea-customizations
|
||||
- full blast radius: service:gitea
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# grimmory (host:grimmory)
|
||||
|
||||
- kind: lxc (LXC 130)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: book-library
|
||||
- address: 192.168.8.247
|
||||
- mounts: /mnt/media_local
|
||||
- doc: knowledge/wiki/containers/130-grimmory.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# haos (host:haos)
|
||||
|
||||
- kind: vm (VM 108)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: home-automation
|
||||
- address: 192.168.8.101 (mesh: tailscale:homeassistant)
|
||||
- doc: knowledge/wiki/vms/108-haos.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:haos
|
||||
- affected by: host:hubris
|
||||
- full blast radius: service:haos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# house (host:house)
|
||||
|
||||
- kind: lxc (LXC 129)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: family-planner
|
||||
- address: 192.168.8.244
|
||||
- doc: knowledge/wiki/containers/129-house.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# hubris (host:hubris)
|
||||
|
||||
- kind: proxmox-host
|
||||
- state: active
|
||||
- role: hypervisor
|
||||
- address: 192.168.8.77 (mesh: netbird:proxmox-server.netbird.selfhosted)
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/hosts/hubris.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:teddycloud, host:trmnl, host:zimaos, service:proxmox_ui
|
||||
- affected by: mount:/mnt/library
|
||||
- full blast radius: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:teddycloud, host:trmnl, host:zimaos, service:artifacto, service:caddy, service:dns, service:gitea, service:haos, service:homelab_mcp, service:nextcloud, service:paperless, service:photos, service:proxmox_ui, service:secrets_issuance, service:teddycloud, service:trmnl, service:zimaos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# jellyfin (host:jellyfin)
|
||||
|
||||
- kind: lxc (LXC 101)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: media-server
|
||||
- address: 192.168.8.246 (mesh: tailscale:jellyfin)
|
||||
- mounts: /mnt/media_local
|
||||
- doc: knowledge/wiki/containers/101-jellyfin.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:jellyfin
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
- full blast radius: service:jellyfin
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,17 +0,0 @@
|
||||
# mac-mini (host:mac-mini)
|
||||
|
||||
- kind: workstation
|
||||
- state: active
|
||||
- role: dev
|
||||
- address: 192.168.178.182 (mesh: netbird:mac-mini-234-17.netbird.selfhosted)
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: (none)
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# mule-images (host:mule-images)
|
||||
|
||||
- kind: lxc (LXC 120)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: photo-management
|
||||
- address: 192.168.8.136 (mesh: tailscale:muleimage)
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/120-mule-images.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:photos
|
||||
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/mule-image
|
||||
- full blast radius: service:photos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,17 +0,0 @@
|
||||
# netbird-vps (host:netbird-vps)
|
||||
|
||||
- kind: external
|
||||
- state: active
|
||||
- role: netbird-mgmt
|
||||
- address: (mesh: netbird:netbird-ionos.netbird.selfhosted)
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:authentik
|
||||
- affected by: (none)
|
||||
- full blast radius: service:authentik
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# nextcloud (host:nextcloud)
|
||||
|
||||
- kind: lxc (LXC 114)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: file-sync
|
||||
- address: 192.168.8.224 (mesh: tailscale:nextcloud)
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/114-nextcloud.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:nextcloud
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
- full blast radius: service:nextcloud
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,18 +0,0 @@
|
||||
# nfs-export (host:nfs-export)
|
||||
|
||||
- kind: lxc (LXC 102)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: storage-export
|
||||
- address: 192.168.8.200
|
||||
- doc: knowledge/wiki/containers/102-nfs-export.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:hubris
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# paperless (host:paperless)
|
||||
|
||||
- kind: lxc (LXC 103)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: document-archive
|
||||
- address: 192.168.8.130 (mesh: tailscale:paperless)
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/103-paperless.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:paperless
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
- full blast radius: service:paperless
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,17 +0,0 @@
|
||||
# rclone (host:rclone)
|
||||
|
||||
- kind: lxc
|
||||
- state: active
|
||||
- role: backup
|
||||
- address: (mesh: netbird:rclone.netbird.selfhosted)
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: (none)
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,16 +0,0 @@
|
||||
# republic-laptop (host:republic-laptop)
|
||||
|
||||
- kind: workstation
|
||||
- state: active
|
||||
- role: primary-dev
|
||||
- address: (mesh: netbird:republic-laptop.netbird.selfhosted)
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: (none)
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# romm (host:romm)
|
||||
|
||||
- kind: lxc (LXC 134)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: rom-manager
|
||||
- address: 192.168.8.249
|
||||
- mounts: /mnt/media_local
|
||||
- doc: knowledge/wiki/containers/134-romm.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong, mount:/mnt/media_local
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# seanime (host:seanime)
|
||||
|
||||
- kind: lxc (LXC 133)
|
||||
- state: active
|
||||
- runs-on: host:strong
|
||||
- role: anime-media-server
|
||||
- address: 192.168.8.248
|
||||
- mounts: /mnt/media_local/anime
|
||||
- doc: knowledge/wiki/containers/133-seanime.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:strong, mount:/mnt/media_local/anime
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# sophia (host:sophia)
|
||||
|
||||
- kind: lxc (LXC 119)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: workshop
|
||||
- address: 192.168.8.109 (mesh: tailscale:sophia)
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/119-sophia.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: (none)
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# strong (host:strong)
|
||||
|
||||
- kind: proxmox-host
|
||||
- state: active
|
||||
- role: hypervisor
|
||||
- address: 192.168.178.181
|
||||
- doc: knowledge/wiki/hosts/strong.md
|
||||
- secrets: enrolled (age key present)
|
||||
|
||||
## Blast radius
|
||||
- impacts: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime
|
||||
- affected by: (none)
|
||||
- full blast radius: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime, service:arr_stack, service:jellyfin, service:matrix
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,20 +0,0 @@
|
||||
# teddycloud (host:teddycloud)
|
||||
|
||||
- kind: lxc (LXC 131)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: teddycloud
|
||||
- address: 192.168.8.150
|
||||
- mounts: /mnt/library
|
||||
- doc: knowledge/wiki/containers/131-teddycloud.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:teddycloud
|
||||
- affected by: host:hubris, mount:/mnt/library
|
||||
- full blast radius: service:teddycloud
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- 2026-07-06T11:05:35+00:00 activate (config_mutation) — ok
|
||||
@@ -1,19 +0,0 @@
|
||||
# trmnl (host:trmnl)
|
||||
|
||||
- kind: lxc (LXC 128)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: trmnl-middleware
|
||||
- address: 192.168.8.211
|
||||
- doc: knowledge/wiki/containers/128-trmnl.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:trmnl
|
||||
- affected by: host:hubris, repo:dtoro/terminalito
|
||||
- full blast radius: service:trmnl
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
@@ -1,19 +0,0 @@
|
||||
# zimaos (host:zimaos)
|
||||
|
||||
- kind: vm (VM 100)
|
||||
- state: active
|
||||
- runs-on: host:hubris
|
||||
- role: nas-frontend-eval
|
||||
- address: 192.168.8.195
|
||||
- doc: knowledge/wiki/vms/100-zimaos.md
|
||||
|
||||
## Blast radius
|
||||
- impacts: service:zimaos
|
||||
- affected by: host:hubris
|
||||
- full blast radius: service:zimaos
|
||||
|
||||
## Safe actions
|
||||
- see the services this host runs for action-level risk classes
|
||||
|
||||
## Recent changes
|
||||
- (none yet)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user