Compare commits

...

11 Commits

Author SHA1 Message Date
juvdiaz 8488c9523e Add backstage Ollama helper for diagnostics 2026-06-27 17:56:15 -06:00
juvdiaz acb6817c98 Add repo-managed Tailscale ACL policy 2026-06-27 17:46:21 -06:00
juvdiaz f38b065599 Expand PXE provisioning rescue menu 2026-06-27 17:40:04 -06:00
juvdiaz 23dd03b0ef Add generated homelab service catalog 2026-06-27 17:34:55 -06:00
juvdiaz e3a1c05a2e Add SOPS age secret management bootstrap 2026-06-27 17:27:05 -06:00
juvdiaz e4b2057f15 Centralize homelab inventory preflight and pihole state 2026-06-27 16:55:28 -06:00
juvdiaz 6070493b1b Seed Uptime Kuma monitors 2026-06-27 16:43:51 -06:00
juvdiaz 7beb0778c9 Add focused homelab doctor checks 2026-06-27 16:35:30 -06:00
juvdiaz d5ff049cae Add homelab inventory 2026-06-27 16:31:04 -06:00
juvdiaz 6ee2e2cb46 Add homelab failure runbooks 2026-06-27 16:23:09 -06:00
juvdiaz 534c87132e Add RPi DNS services stack 2026-06-27 16:18:17 -06:00
38 changed files with 3874 additions and 76 deletions

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ infra/youtube-backup/logs/
*.secret.local.yaml
.age-key.txt
sops-age.key
.sops.yaml.tmp
# Ignore older source iterations
*.old

View File

@ -1,6 +1,6 @@
# Copy this file to .sops.yaml after replacing the age recipient with the
# public key generated on the Debian homelab server.
creation_rules:
- path_regex: '(^|/).*\.(secret|enc)\.ya?ml$'
encrypted_regex: '^(data|stringData|values)$'
- path_regex: '(^|/).*\.(secret|enc)\.(ya?ml|json)$'
encrypted_regex: '^(data|stringData|values|password|token|secret|key|credentials)$'
age: age1replacewithyourpublicrecipient

133
README.md
View File

@ -1,6 +1,6 @@
# Homelab Kubernetes Pipeline
<!-- Generated from README.md.tmpl by scripts/render-docs. Edit README.md.tmpl and docs.yml, then regenerate. -->
<!-- Generated from README.md.tmpl by scripts/render-docs. Edit README.md.tmpl and homelab.yml, then regenerate. -->
This repo bootstraps a hybrid kubeadm cluster and then hands app delivery to
Argo CD.
@ -30,6 +30,21 @@ The lab is intentionally small but production-shaped:
Tailscale, with nginx caching, gzip, HTTP/2, TLS session reuse, and upstream
keepalive
## Inventory
The canonical non-secret inventory is [homelab.yml](homelab.yml). Keep LAN IPs,
Tailscale IPs, public hostnames, ports, storage paths, and service ownership
there before copying values into scripts, OpenTofu variables, runbooks, or
service docs. Secrets and tokens do not belong in that file.
`./jeannie` reads `homelab.yml` at startup and exports matching OpenTofu
variables for the edge, provisioning, cluster, platform, and apps stacks. This
keeps values such as the Debian LAN IP, Debian Tailscale IP, RPi LAN IP, OCI IP,
Traefik MetalLB IP, and domain name from drifting across scripts and templates.
`scripts/render-docs` also renders [docs/service-catalog.md](docs/service-catalog.md)
and the static service catalog page at
`apps/demos-static/public/homelab-catalog/index.html` from the same inventory.
Run `./jeannie up` and `./jeannie nuke` only from the Debian homelab server. The
script intentionally refuses to run from non-Debian machines so a laptop cannot
accidentally modify the cluster.
@ -39,6 +54,7 @@ accidentally modify the cluster.
1. `bootstrap/provisioning`
- prepares a Debian server as a PXE and preseed service for arm64 VMs
- serves Debian 13 arm64 netboot assets through TFTP and HTTP
- exposes a PXE rescue menu and HTTP rescue page for manual repair paths
- creates a golden image install path with Kubernetes, containerd,
qemu-guest-agent, cloud-init, and storage client packages ready
- is driven by `./jeannie up` when Pimox is reachable, without changing
@ -204,6 +220,16 @@ service is already managed manually. The default Gitea target is
`jv@192.168.100.73`, install directory `/data/homelab-gitea`, HTTP port `3000`,
and SSH port `32222`.
Before continuing into Pimox, cluster, apps, or edge changes, `./jeannie up`
runs `./jeannie preflight`. The preflight verifies Gitea, Debian Docker
root, Debian Tailscale IP, RPi Docker root fallback state, RPi Tailscale IP,
Pimox worker storage, and OCI edge SSH. Run it directly when changing inventory
or host storage/networking:
```bash
./jeannie preflight
```
## Validation
Useful checks after a rebuild:
@ -235,8 +261,57 @@ Run a read-only health snapshot from the Debian server with:
```
It reports host memory/disk, systemd services, Docker Compose stacks,
Kubernetes health when the API is reachable, Pimox worker VM status, Tailscale,
and key local/public HTTP endpoints.
Kubernetes health when the API is reachable, Pimox worker VM status, RPi
services, Tailscale, and key local/public HTTP endpoints.
Focused doctor commands run narrower read-only checks and print the most likely
next step:
```bash
./jeannie doctor-edge
./jeannie doctor-gitea
./jeannie doctor-rpi
./jeannie doctor-cluster
```
## RPi Services
The Raspberry Pi runs lightweight always-on services outside Kubernetes from
`infra/rpi-services`:
- Pi-hole on DNS port `53` and web port `8081`
- Unbound as Pi-hole's first upstream resolver
- Uptime Kuma on port `3001`
Deploy or refresh them from the Debian server with:
```bash
./jeannie rpi-services
```
`./jeannie up` also runs this step unless
`LAB_RPI_SERVICES_DEPLOY=false` is set. The bootstrap prefers Docker data under
`/nvme-storage/docker` only when that path is an active writable mount point. If
the NVMe is missing or resets, it rewrites Docker's `data-root` to
`/var/lib/docker` and restarts Docker so DNS can still come up from the
SD/root filesystem.
Pi-hole uses Unbound first and public fallback upstreams after that, so DNS can
continue resolving if the Unbound container stops. Repo-managed Pi-hole state
lives in `infra/rpi-services/adlists.txt`,
`infra/rpi-services/local-dns-records.txt`,
`infra/rpi-services/cname-records.txt`, and
`infra/rpi-services/static-dhcp-hosts.txt`. The bootstrap inserts adlists without
deleting manually added Pi-hole lists, then renders the DNS/CNAME/DHCP files into
a managed dnsmasq config inside the Pi-hole container. If `PIHOLE_WEBPASSWORD`
is not provided, the bootstrap generates one and keeps it in
`/opt/homelab-rpi-services/.env`.
Uptime Kuma monitor seeds live in
`infra/rpi-services/uptime-kuma-monitors.json` and are applied after the first
Kuma user exists.
`./jeannie status` and `./jeannie doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`.
## Adding Nodes
@ -480,14 +555,22 @@ Traefik uses MetalLB for a LAN `LoadBalancer` address. App services such as the
website, demos, and Heimdall should be `ClusterIP` services behind Kubernetes
Ingress objects. The local registry remains a `NodePort` because the cluster
nodes use it as a pull endpoint. Gitea is not a Kubernetes service; it runs on
the Raspberry Pi Docker host.
the Debian Docker host.
## Secrets
Use SOPS with age for secrets that need to live in Git. Start from
`.sops.yaml.example`, replace the age recipient with the public key generated on
the Debian host, and commit the resulting `.sops.yaml`. Keep the private age key
outside the repo. Operational notes are in `docs/secrets.md`.
Use SOPS with age for secrets that need to live in Git. The Debian host
bootstrap installs `age` and `sops`; then run:
```bash
./jeannie secrets-init
./jeannie secrets-check
```
`secrets-init` creates `~/.config/sops/age/keys.txt` if needed and renders
`.sops.yaml` from `.sops.yaml.example` with the host's public age recipient.
Commit `.sops.yaml`, but keep the private age key outside the repo. Operational
notes are in `docs/secrets.md`.
## Edge Services
@ -497,6 +580,13 @@ The OCI jump box runs the public edge path:
nginx -> HAProxy -> Varnish/Squid -> Traefik MetalLB IP
```
Tailnet access policy is tracked in `infra/tailscale/tailnet-policy.hujson`.
Validate it before applying with:
```bash
./jeannie tailnet-policy-check
```
The `bootstrap/edge` stack renders configs from `bootstrap/edge/templates` and
deploys them to `/opt/homelab-edge` on the OCI host. Defaults are in
`bootstrap/edge/variables.tf`; override them through `TF_VAR_*` or a `.tfvars`
@ -504,9 +594,9 @@ file when the public host, SSH key, server name, backend Tailscale IP, or
Traefik backend address changes.
The `/git/` route is intentionally different from the Kubernetes app routes: it
proxies to Gitea on the Raspberry Pi at the configured `gitea_backend_port`
instead of Traefik. This keeps public read-only source browsing available even
when the cluster has been destroyed.
proxies to Gitea on the Debian host through Tailscale instead of Traefik. This
keeps public read-only source browsing available even when the cluster has been
destroyed.
For the main website, nginx terminates TLS, serves cached HTML and static
assets, compresses text responses with gzip, advertises HTTP/2, reuses TLS
@ -529,6 +619,12 @@ be open before `./jeannie up` runs. Set `TF_VAR_letsencrypt_email` to receive
expiry notices, or leave it empty to register without an email. Set
`TF_VAR_enable_letsencrypt=false` to keep using the temporary local certificate.
Operational runbooks:
- [Edge failures](docs/runbooks/edge-failures.md)
- [Gitea failures](docs/runbooks/gitea-failures.md)
- [Cluster stop/start failures](docs/runbooks/cluster-stop-start-failures.md)
## Adding Apps
Add Kubernetes manifests under `apps/<name>` and register them in
@ -757,6 +853,21 @@ other nodes can reach `OLLAMA_HOST=http://192.168.100.73:11434`:
./jeannie website-ollama-listen
```
`jeannie` also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when `ai_gateway.enabled` is true in
`homelab.yml`, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
```bash
ollama pull qwen2.5:0.5b
```
Disable it for low-CPU sessions with:
```bash
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
```
The CV page has two client-side presentation modes:
- `Elegant`: dark, minimal, terminal-inspired styling with a square profile

View File

@ -1,6 +1,6 @@
# Homelab Kubernetes Pipeline
<!-- Generated from README.md.tmpl by scripts/render-docs. Edit README.md.tmpl and docs.yml, then regenerate. -->
<!-- Generated from README.md.tmpl by scripts/render-docs. Edit README.md.tmpl and homelab.yml, then regenerate. -->
This repo bootstraps a hybrid kubeadm cluster and then hands app delivery to
Argo CD.
@ -30,6 +30,21 @@ The lab is intentionally small but production-shaped:
Tailscale, with nginx caching, gzip, HTTP/2, TLS session reuse, and upstream
keepalive
## Inventory
The canonical non-secret inventory is [homelab.yml](homelab.yml). Keep LAN IPs,
Tailscale IPs, public hostnames, ports, storage paths, and service ownership
there before copying values into scripts, OpenTofu variables, runbooks, or
service docs. Secrets and tokens do not belong in that file.
`./{{ main_script }}` reads `homelab.yml` at startup and exports matching OpenTofu
variables for the edge, provisioning, cluster, platform, and apps stacks. This
keeps values such as the Debian LAN IP, Debian Tailscale IP, RPi LAN IP, OCI IP,
Traefik MetalLB IP, and domain name from drifting across scripts and templates.
`scripts/render-docs` also renders [docs/service-catalog.md](docs/service-catalog.md)
and the static service catalog page at
`apps/demos-static/public/homelab-catalog/index.html` from the same inventory.
Run `./{{ main_script }} up` and `./{{ main_script }} nuke` only from the Debian homelab server. The
script intentionally refuses to run from non-Debian machines so a laptop cannot
accidentally modify the cluster.
@ -39,6 +54,7 @@ accidentally modify the cluster.
1. `bootstrap/provisioning`
- prepares a Debian server as a PXE and preseed service for arm64 VMs
- serves Debian 13 arm64 netboot assets through TFTP and HTTP
- exposes a PXE rescue menu and HTTP rescue page for manual repair paths
- creates a golden image install path with Kubernetes, containerd,
qemu-guest-agent, cloud-init, and storage client packages ready
- is driven by `./{{ main_script }} up` when Pimox is reachable, without changing
@ -204,6 +220,16 @@ service is already managed manually. The default Gitea target is
`jv@192.168.100.73`, install directory `/data/homelab-gitea`, HTTP port `3000`,
and SSH port `32222`.
Before continuing into Pimox, cluster, apps, or edge changes, `./{{ main_script }} up`
runs `./{{ main_script }} preflight`. The preflight verifies Gitea, Debian Docker
root, Debian Tailscale IP, RPi Docker root fallback state, RPi Tailscale IP,
Pimox worker storage, and OCI edge SSH. Run it directly when changing inventory
or host storage/networking:
```bash
./{{ main_script }} preflight
```
## Validation
Useful checks after a rebuild:
@ -235,8 +261,57 @@ Run a read-only health snapshot from the Debian server with:
```
It reports host memory/disk, systemd services, Docker Compose stacks,
Kubernetes health when the API is reachable, Pimox worker VM status, Tailscale,
and key local/public HTTP endpoints.
Kubernetes health when the API is reachable, Pimox worker VM status, RPi
services, Tailscale, and key local/public HTTP endpoints.
Focused doctor commands run narrower read-only checks and print the most likely
next step:
```bash
./{{ main_script }} doctor-edge
./{{ main_script }} doctor-gitea
./{{ main_script }} doctor-rpi
./{{ main_script }} doctor-cluster
```
## RPi Services
The Raspberry Pi runs lightweight always-on services outside Kubernetes from
`infra/rpi-services`:
- Pi-hole on DNS port `53` and web port `8081`
- Unbound as Pi-hole's first upstream resolver
- Uptime Kuma on port `3001`
Deploy or refresh them from the Debian server with:
```bash
./{{ main_script }} rpi-services
```
`./{{ main_script }} up` also runs this step unless
`LAB_RPI_SERVICES_DEPLOY=false` is set. The bootstrap prefers Docker data under
`/nvme-storage/docker` only when that path is an active writable mount point. If
the NVMe is missing or resets, it rewrites Docker's `data-root` to
`/var/lib/docker` and restarts Docker so DNS can still come up from the
SD/root filesystem.
Pi-hole uses Unbound first and public fallback upstreams after that, so DNS can
continue resolving if the Unbound container stops. Repo-managed Pi-hole state
lives in `infra/rpi-services/adlists.txt`,
`infra/rpi-services/local-dns-records.txt`,
`infra/rpi-services/cname-records.txt`, and
`infra/rpi-services/static-dhcp-hosts.txt`. The bootstrap inserts adlists without
deleting manually added Pi-hole lists, then renders the DNS/CNAME/DHCP files into
a managed dnsmasq config inside the Pi-hole container. If `PIHOLE_WEBPASSWORD`
is not provided, the bootstrap generates one and keeps it in
`/opt/homelab-rpi-services/.env`.
Uptime Kuma monitor seeds live in
`infra/rpi-services/uptime-kuma-monitors.json` and are applied after the first
Kuma user exists.
`./{{ main_script }} status` and `./{{ main_script }} doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`.
## Adding Nodes
@ -480,14 +555,22 @@ Traefik uses MetalLB for a LAN `LoadBalancer` address. App services such as the
website, demos, and Heimdall should be `ClusterIP` services behind Kubernetes
Ingress objects. The local registry remains a `NodePort` because the cluster
nodes use it as a pull endpoint. Gitea is not a Kubernetes service; it runs on
the Raspberry Pi Docker host.
the Debian Docker host.
## Secrets
Use SOPS with age for secrets that need to live in Git. Start from
`.sops.yaml.example`, replace the age recipient with the public key generated on
the Debian host, and commit the resulting `.sops.yaml`. Keep the private age key
outside the repo. Operational notes are in `docs/secrets.md`.
Use SOPS with age for secrets that need to live in Git. The Debian host
bootstrap installs `age` and `sops`; then run:
```bash
./{{ main_script }} secrets-init
./{{ main_script }} secrets-check
```
`secrets-init` creates `~/.config/sops/age/keys.txt` if needed and renders
`.sops.yaml` from `.sops.yaml.example` with the host's public age recipient.
Commit `.sops.yaml`, but keep the private age key outside the repo. Operational
notes are in `docs/secrets.md`.
## Edge Services
@ -497,6 +580,13 @@ The OCI jump box runs the public edge path:
nginx -> HAProxy -> Varnish/Squid -> Traefik MetalLB IP
```
Tailnet access policy is tracked in `infra/tailscale/tailnet-policy.hujson`.
Validate it before applying with:
```bash
./{{ main_script }} tailnet-policy-check
```
The `bootstrap/edge` stack renders configs from `bootstrap/edge/templates` and
deploys them to `/opt/homelab-edge` on the OCI host. Defaults are in
`bootstrap/edge/variables.tf`; override them through `TF_VAR_*` or a `.tfvars`
@ -504,9 +594,9 @@ file when the public host, SSH key, server name, backend Tailscale IP, or
Traefik backend address changes.
The `/git/` route is intentionally different from the Kubernetes app routes: it
proxies to Gitea on the Raspberry Pi at the configured `gitea_backend_port`
instead of Traefik. This keeps public read-only source browsing available even
when the cluster has been destroyed.
proxies to Gitea on the Debian host through Tailscale instead of Traefik. This
keeps public read-only source browsing available even when the cluster has been
destroyed.
For the main website, nginx terminates TLS, serves cached HTML and static
assets, compresses text responses with gzip, advertises HTTP/2, reuses TLS
@ -529,6 +619,12 @@ be open before `./{{ main_script }} up` runs. Set `TF_VAR_letsencrypt_email` to
expiry notices, or leave it empty to register without an email. Set
`TF_VAR_enable_letsencrypt=false` to keep using the temporary local certificate.
Operational runbooks:
- [Edge failures](docs/runbooks/edge-failures.md)
- [Gitea failures](docs/runbooks/gitea-failures.md)
- [Cluster stop/start failures](docs/runbooks/cluster-stop-start-failures.md)
## Adding Apps
Add Kubernetes manifests under `apps/<name>` and register them in
@ -757,6 +853,21 @@ other nodes can reach `OLLAMA_HOST=http://192.168.100.73:11434`:
./{{ main_script }} website-ollama-listen
```
`{{ main_script }}` also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when `ai_gateway.enabled` is true in
`homelab.yml`, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
```bash
ollama pull qwen2.5:0.5b
```
Disable it for low-CPU sessions with:
```bash
LAB_BACKSTAGE_BRAIN_ENABLED=false ./{{ main_script }} doctor-edge
```
The CV page has two client-side presentation modes:
- `Elegant`: dark, minimal, terminal-inspired styling with a square profile

View File

@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab Service Catalog</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body class="theme-dark">
<main class="shell catalog-shell">
<nav class="topline">
<a href="../">All demos</a>
<a href="/demos.php">Website catalog</a>
</nav>
<section class="hero catalog-hero">
<p class="kicker">Inventory view</p>
<h1>Homelab Service Catalog</h1>
<p>Generated from <code>homelab.yml</code>. Use this as the operational map for hosts, ports, public routes, local URLs, ownership, and runbooks.</p>
</section>
<section class="grid host-grid" aria-label="Homelab hosts">
<article class="catalog-card service-host">
<span>control-plane</span>
<h2>debian</h2>
<p><strong>Address:</strong> 192.168.100.73</p>
<p><strong>Tailscale:</strong> 100.85.138.30</p>
<p><strong>Storage:</strong> /data/docker</p>
</article>
<article class="catalog-card service-host">
<span>dns-edge-worker</span>
<h2>rpi4</h2>
<p><strong>Address:</strong> 192.168.100.89</p>
<p><strong>Tailscale:</strong> 100.77.80.72</p>
<p><strong>Storage:</strong> /nvme-storage/docker</p>
</article>
<article class="catalog-card service-host">
<span>pimox</span>
<h2>opi5_pimox</h2>
<p><strong>Address:</strong> 192.168.100.80</p>
<p><strong>Tailscale:</strong> n/a</p>
<p><strong>Storage:</strong> opi5_ssd</p>
</article>
<article class="catalog-card service-host">
<span>public-edge</span>
<h2>oci_edge</h2>
<p><strong>Address:</strong> 132.145.170.74</p>
<p><strong>Tailscale:</strong> 100.118.255.19</p>
<p><strong>Storage:</strong> n/a</p>
</article>
</section>
<section class="panel service-table-panel" aria-label="Homelab services">
<div class="table-scroll">
<table class="service-table">
<thead>
<tr>
<th>Service</th>
<th>Host</th>
<th>Managed by</th>
<th>Local</th>
<th>Public</th>
<th>Docs</th>
</tr>
</thead>
<tbody>
<tr>
<td>Website</td>
<td>Kubernetes</td>
<td>Argo CD / apps/website</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://lab2025.duckdns.org">https://lab2025.duckdns.org</a></td>
<td><code>apps/website/README.md</code></td>
</tr>
<tr>
<td>Demo Apps</td>
<td>Kubernetes</td>
<td>Argo CD / apps/demos-static</td>
<td><a href="http://192.168.100.240/demo-apps/">http://192.168.100.240/demo-apps/</a></td>
<td><a href="https://demos.lab2025.duckdns.org/">https://demos.lab2025.duckdns.org/</a></td>
<td><code>apps/demos-static/</code></td>
</tr>
<tr>
<td>Service Catalog</td>
<td>Kubernetes</td>
<td>Generated from homelab.yml</td>
<td><a href="http://192.168.100.240/demo-apps/homelab-catalog/">http://192.168.100.240/demo-apps/homelab-catalog/</a></td>
<td><a href="https://demos.lab2025.duckdns.org/homelab-catalog/">https://demos.lab2025.duckdns.org/homelab-catalog/</a></td>
<td><code>docs/service-catalog.md</code></td>
</tr>
<tr>
<td>Gitea</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / jeannie deploy-gitea</td>
<td><a href="http://192.168.100.73:3000/">http://192.168.100.73:3000/</a></td>
<td><a href="https://lab2025.duckdns.org/git/">https://lab2025.duckdns.org/git/</a></td>
<td><code>infra/gitea/README.md</code></td>
</tr>
<tr>
<td>GitOps mirror</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Bare Git repository</td>
<td><code>/home/jv/git-server/my-homelab-configs.git</code></td>
<td><a href="ssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.git">ssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.git</a></td>
<td><code>README.md#gitops-and-gitea-actions</code></td>
</tr>
<tr>
<td>Container Registry</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Argo CD / apps/container-registry</td>
<td><a href="http://192.168.100.73:30500/v2/_catalog">http://192.168.100.73:30500/v2/_catalog</a></td>
<td><span class="muted">not exposed</span></td>
<td><code>apps/container-registry/</code></td>
</tr>
<tr>
<td>Traefik</td>
<td>Kubernetes</td>
<td>OpenTofu / bootstrap/platform</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://lab2025.duckdns.org">https://lab2025.duckdns.org</a></td>
<td><code>README.md#loadbalancer-services</code></td>
</tr>
<tr>
<td>Pi-hole</td>
<td>rpi4 (dns-edge-worker, 192.168.100.89)</td>
<td>Docker Compose / jeannie rpi-services</td>
<td><a href="http://192.168.100.89:8081/admin/">http://192.168.100.89:8081/admin/</a></td>
<td><span class="muted">not exposed</span></td>
<td><code>infra/rpi-services/README.md</code></td>
</tr>
<tr>
<td>Unbound</td>
<td>rpi4 (dns-edge-worker, 192.168.100.89)</td>
<td>Docker Compose / jeannie rpi-services</td>
<td><code>192.168.100.89:53</code></td>
<td><span class="muted">not exposed</span></td>
<td><code>infra/rpi-services/README.md</code></td>
</tr>
<tr>
<td>Uptime Kuma</td>
<td>rpi4 (dns-edge-worker, 192.168.100.89)</td>
<td>Docker Compose / jeannie rpi-services</td>
<td><a href="http://192.168.100.89:3001/">http://192.168.100.89:3001/</a></td>
<td><span class="muted">not exposed</span></td>
<td><code>infra/rpi-services/README.md</code></td>
</tr>
<tr>
<td>Provisioning PXE/HTTP</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>OpenTofu / bootstrap/provisioning</td>
<td><a href="http://192.168.100.73:8088/">http://192.168.100.73:8088/</a></td>
<td><span class="muted">not exposed</span></td>
<td><code>bootstrap/provisioning/README.md</code></td>
</tr>
<tr>
<td>PXE Rescue Toolkit</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>OpenTofu / bootstrap/provisioning</td>
<td><a href="http://192.168.100.73:8088/rescue/">http://192.168.100.73:8088/rescue/</a></td>
<td><span class="muted">not exposed</span></td>
<td><code>bootstrap/provisioning/README.md</code></td>
</tr>
<tr>
<td>Ollama</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Local Debian service</td>
<td><a href="http://192.168.100.73:11434">http://192.168.100.73:11434</a></td>
<td><span class="muted">not exposed</span></td>
<td><code>apps/website/ollama/README.md</code></td>
</tr>
<tr>
<td>OCI Edge</td>
<td>oci_edge</td>
<td>OpenTofu / bootstrap/edge</td>
<td><code>/opt/homelab-edge</code></td>
<td><a href="https://lab2025.duckdns.org">https://lab2025.duckdns.org</a></td>
<td><code>docs/runbooks/edge-failures.md</code></td>
</tr>
<tr>
<td>Heimdall</td>
<td>Kubernetes</td>
<td>Argo CD / apps/heimdall</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://heimdall.lab2025.duckdns.org/">https://heimdall.lab2025.duckdns.org/</a></td>
<td><code>apps/heimdall/</code></td>
</tr>
<tr>
<td>n8n</td>
<td>Kubernetes</td>
<td>Argo CD / apps/n8n</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://n8n.lab2025.duckdns.org/">https://n8n.lab2025.duckdns.org/</a></td>
<td><code>apps/n8n/</code></td>
</tr>
<tr>
<td>Argo CD</td>
<td>Kubernetes</td>
<td>OpenTofu / bootstrap/platform</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://argocd.lab2025.duckdns.org/">https://argocd.lab2025.duckdns.org/</a></td>
<td><code>bootstrap/platform/</code></td>
</tr>
<tr>
<td>Grafana</td>
<td>Kubernetes</td>
<td>OpenTofu / bootstrap/platform</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://grafana.lab2025.duckdns.org/">https://grafana.lab2025.duckdns.org/</a></td>
<td><code>bootstrap/platform/</code></td>
</tr>
<tr>
<td>Prometheus</td>
<td>Kubernetes</td>
<td>OpenTofu / bootstrap/platform</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://prometheus.lab2025.duckdns.org/">https://prometheus.lab2025.duckdns.org/</a></td>
<td><code>bootstrap/platform/</code></td>
</tr>
<tr>
<td>Alertmanager</td>
<td>Kubernetes</td>
<td>OpenTofu / bootstrap/platform</td>
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
<td><a href="https://alertmanager.lab2025.duckdns.org/">https://alertmanager.lab2025.duckdns.org/</a></td>
<td><code>bootstrap/platform/</code></td>
</tr>
<tr>
<td>Prowlarr</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:9696/">http://192.168.100.73:9696/</a></td>
<td><a href="https://prowlarr.lab2025.duckdns.org/">https://prowlarr.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
<tr>
<td>Sonarr</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:8989/">http://192.168.100.73:8989/</a></td>
<td><a href="https://sonarr.lab2025.duckdns.org/">https://sonarr.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
<tr>
<td>Radarr</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:7878/">http://192.168.100.73:7878/</a></td>
<td><a href="https://radarr.lab2025.duckdns.org/">https://radarr.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
<tr>
<td>qBittorrent</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:8080/">http://192.168.100.73:8080/</a></td>
<td><a href="https://qbittorrent.lab2025.duckdns.org/">https://qbittorrent.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
<tr>
<td>Kapowarr</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:5656/">http://192.168.100.73:5656/</a></td>
<td><a href="https://kapowarr.lab2025.duckdns.org/">https://kapowarr.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
<tr>
<td>Suwayomi</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:4567/">http://192.168.100.73:4567/</a></td>
<td><a href="https://suwayomi.lab2025.duckdns.org/">https://suwayomi.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
<tr>
<td>Maintainerr</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:6246/">http://192.168.100.73:6246/</a></td>
<td><a href="https://maintainerr.lab2025.duckdns.org/">https://maintainerr.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
<tr>
<td>FlareSolverr</td>
<td>debian (control-plane, 192.168.100.73)</td>
<td>Docker Compose / infra/arr-stack</td>
<td><a href="http://192.168.100.73:8191/">http://192.168.100.73:8191/</a></td>
<td><a href="https://flaresolverr.lab2025.duckdns.org/">https://flaresolverr.lab2025.duckdns.org/</a></td>
<td><code>infra/arr-stack/README.md</code></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="panel command-panel">
<h2>Focused Checks</h2>
<p><code>./jeannie status</code> <code>./jeannie preflight</code> <code>./jeannie doctor-edge</code> <code>./jeannie doctor-gitea</code> <code>./jeannie doctor-rpi</code> <code>./jeannie doctor-cluster</code></p>
</section>
</main>
<script src="../theme.js"></script>
</body>
</html>

View File

@ -19,6 +19,7 @@
</section>
<section class="grid">
<a class="catalog-card" href="homelab-catalog/"><span>Ops 01</span><h2>Service Catalog</h2><p>Generated host, service, route, and runbook map from homelab.yml.</p></a>
<a class="catalog-card" href="media-cruncher/"><span>Demo 01</span><h2>Media Cruncher</h2><p>Local image compression and conversion.</p></a>
<a class="catalog-card" href="network-quality/"><span>Demo 02</span><h2>Internet Quality</h2><p>Latency, jitter, and stability graph.</p></a>
<a class="catalog-card" href="dev-toolbelt/"><span>Demo 03</span><h2>Log and JSON Toolbelt</h2><p>JSON, JWT, URL, and log utilities.</p></a>

View File

@ -115,6 +115,71 @@ h1 {
line-height: 1.5;
}
.catalog-shell {
width: min(1280px, calc(100% - 32px));
}
.catalog-hero code,
.command-panel code,
.service-table code {
background: rgba(47, 133, 90, 0.12);
border: 1px solid rgba(47, 133, 90, 0.32);
border-radius: 6px;
padding: 2px 6px;
}
.host-grid {
margin-bottom: 18px;
}
.service-host {
min-height: 0;
}
.service-host p {
margin: 8px 0 0;
}
.table-scroll {
overflow-x: auto;
}
.service-table {
width: 100%;
border-collapse: collapse;
min-width: 980px;
}
.service-table th,
.service-table td {
border-bottom: 1px solid #d9e2ec;
padding: 12px 10px;
text-align: left;
vertical-align: top;
}
.service-table th {
color: #102a43;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.service-table td {
color: #334e68;
line-height: 1.45;
}
.service-table a {
color: #0b63ce;
overflow-wrap: anywhere;
text-decoration: none;
}
.muted {
color: #829ab1;
}
.drop-zone {
display: grid;
place-items: center;
@ -437,6 +502,25 @@ body.theme-dark .stats span {
box-shadow: 0 0 16px rgba(57, 255, 20, 0.16);
}
body.theme-dark .service-table th,
body.theme-dark .service-table td,
body.theme-dark .service-table code,
body.theme-dark .muted {
border-color: #1fbf3a;
color: #39ff14;
}
body.theme-dark .service-table a {
color: #7cff7c;
}
body.theme-dark .service-table code,
body.theme-dark .catalog-hero code,
body.theme-dark .command-panel code {
background: #031503;
border-color: #1fbf3a;
}
body.theme-dark button,
body.theme-dark .download-link,
body.theme-dark .site-theme-option {
@ -497,6 +581,18 @@ body.theme-light .stats span {
box-shadow: none;
}
body.theme-light .service-table th,
body.theme-light .service-table td,
body.theme-light .service-table code,
body.theme-light .muted {
border-color: #111;
color: #1b1610;
}
body.theme-light .service-table a {
color: #111;
}
body.theme-light button,
body.theme-light .download-link,
body.theme-light .site-theme-option {

View File

@ -33,10 +33,17 @@ data:
"colour": "#2563eb",
"class": "Demos"
},
{
"title": "Service Catalog",
"url": "https://demos.lab2025.duckdns.org/homelab-catalog/",
"description": "Generated host, service, route, and runbook map from homelab.yml",
"colour": "#0891b2",
"class": "Catalog"
},
{
"title": "Gitea",
"url": "https://lab2025.duckdns.org/git/",
"description": "External Git service on the Raspberry Pi",
"description": "External Git service on the Debian host",
"colour": "#609926",
"class": "Gitea"
},

View File

@ -30,6 +30,7 @@ The playbook:
the current Debian release
- installs user-level pipx CLI tools such as `yt-dlp`, plus runtime helpers
such as `ffmpeg` and `nodejs`
- installs `age` and `sops` for encrypted repo-managed secrets
- installs Zoom from the upstream `.deb` when requested on amd64
- adds `jv` to expected local groups
- enables core services such as Docker, containerd, kubelet, ssh, cron, and
@ -40,3 +41,10 @@ The playbook:
Secrets and active sessions are intentionally not stored in this repo. Keep
SSH keys, kubeconfigs, SOPS keys, Docker auth, and browser session/password data
in a separate encrypted backup if you want them restored.
After the playbook finishes, initialize the Debian host's SOPS identity with:
```bash
./jeannie secrets-init
./jeannie secrets-check
```

View File

@ -38,6 +38,7 @@ debian_pc_hold_packages:
- kubelet
debian_pc_base_packages:
- age
- apt-transport-https
- bash-completion
- ca-certificates
@ -50,6 +51,7 @@ debian_pc_base_packages:
- python3
- python3-apt
- rsync
- sops
- sudo
- tar
- unzip

View File

@ -13,6 +13,7 @@ to edit Orange Pi host networking.
- `nginx` for preseed, installer, and guest-prep scripts
- Debian 13 arm64 netboot assets under `/opt/homelab-provisioning`
- a preseed file for unattended Debian install
- a GRUB PXE rescue menu and HTTP rescue page at `/rescue/`
- guest prep scripts that install Kubernetes tools, containerd, qemu guest agent, cloud-init, OpenEBS dependencies, cgroup prerequisites, swap disablement, and the local registry trust path
- Kubernetes tools from the pinned minor in `TF_VAR_kubernetes_minor_version`, defaulting to `v1.36`
- `containerd.io` from Docker's Debian repository so worker runtimes use the
@ -60,7 +61,25 @@ generates a unique fallback name using `TF_VAR_clone_hostname_prefix`.
Create an arm64 VM in Pimox with UEFI firmware, a virtio disk, and a NIC on the same LAN as the Debian provisioning host. Put network boot first.
PXE should load `grubaa64.efi`, boot the Debian installer, fetch the preseed from `http://192.168.100.73:8088/preseed/debian13-arm64-worker.cfg`, and install the golden image.
PXE should load `grubaa64.efi` and display a GRUB menu. The default entry keeps
the automated template flow intact: it boots the Debian installer, fetches the
preseed from `http://192.168.100.73:8088/preseed/debian13-arm64-worker.cfg`, and
installs the golden image.
The same menu also includes rescue-oriented entries:
- Debian 13 arm64 rescue shell
- Debian 13 arm64 expert/manual install
- Debian 13 arm64 automated install, verbose
- UEFI firmware settings
- Exit PXE and continue local boot
- Reboot
The Debian provisioning host also publishes a rescue landing page:
```bash
curl http://192.168.100.73:8088/rescue/
```
If your Pimox firmware needs a different Debian arm64 EFI loader, override `TF_VAR_pxe_boot_file`.

View File

@ -64,9 +64,17 @@ locals {
})
grub_cfg = templatefile("${path.module}/templates/grub.cfg.tftpl", {
preseed_url = local.preseed_url
template_hostname = var.template_hostname
template_domain = var.template_domain
preseed_url = local.preseed_url
template_hostname = var.template_hostname
template_domain = var.template_domain
pxe_default_menu_entry = tostring(var.pxe_default_menu_entry)
pxe_menu_timeout = tostring(var.pxe_menu_timeout)
})
rescue_index = templatefile("${path.module}/templates/rescue-index.html.tftpl", {
http_base_url = local.provisioning_http_base_url
preseed_url = local.preseed_url
tftp_root = local.tftp_root
})
preseed_cfg = templatefile("${path.module}/templates/preseed.cfg.tftpl", {
@ -106,6 +114,7 @@ locals {
local.dnsmasq_conf,
local.nginx_conf,
local.grub_cfg,
local.rescue_index,
local.preseed_cfg,
local.golden_node_prepare,
local.prepare_template,
@ -421,7 +430,7 @@ resource "null_resource" "provisioning_host" {
http_port = tostring(var.http_port)
config_hash = local.config_hash
provisioning_layer = "1"
provisioner_version = "3"
provisioner_version = "4"
}
connection {
@ -453,6 +462,11 @@ resource "null_resource" "provisioning_host" {
destination = "/tmp/homelab-provisioning/grub.cfg"
}
provisioner "file" {
content = local.rescue_index
destination = "/tmp/homelab-provisioning/rescue-index.html"
}
provisioner "file" {
content = local.preseed_cfg
destination = "/tmp/homelab-provisioning/preseed.cfg"
@ -503,17 +517,20 @@ sudo install -d -m 0755 \
"$tftp_root/grub" \
"$http_root" \
"$http_root/preseed" \
"$http_root/rescue" \
"$http_root/scripts" \
"$http_root/debian-installer/arm64"
sudo cp "$tmp_dir/grub.cfg" "$tftp_root/grub/grub.cfg"
sudo cp "$tmp_dir/grub.cfg" "$tftp_root/debian-installer/arm64/grub/grub.cfg"
sudo cp "$tmp_dir/rescue-index.html" "$http_root/rescue/index.html"
sudo cp "$tmp_dir/preseed.cfg" "$http_root/preseed/debian13-arm64-worker.cfg"
sudo cp "$tmp_dir/golden-node-prepare.sh" "$http_root/scripts/golden-node-prepare.sh"
sudo cp "$tmp_dir/prepare-template.sh" "$http_root/scripts/prepare-template.sh"
sudo chmod 0644 \
"$tftp_root/grub/grub.cfg" \
"$tftp_root/debian-installer/arm64/grub/grub.cfg" \
"$http_root/rescue/index.html" \
"$http_root/preseed/debian13-arm64-worker.cfg"
sudo chmod 0755 "$http_root/scripts/golden-node-prepare.sh" "$http_root/scripts/prepare-template.sh"

View File

@ -6,6 +6,10 @@ output "preseed_url" {
value = local.preseed_url
}
output "pxe_rescue_url" {
value = "${local.provisioning_http_base_url}/rescue/"
}
output "pxe_boot_file" {
value = var.pxe_boot_file
}

View File

@ -1,7 +1,34 @@
set default=0
set timeout=5
set default=${pxe_default_menu_entry}
set timeout=${pxe_menu_timeout}
menuentry 'Debian 13 arm64 homelab worker template' {
menuentry 'Debian 13 arm64 automated homelab worker template' --id debian-auto-template {
linux /debian-installer/arm64/linux auto=true priority=critical url=${preseed_url} interface=auto hostname=${template_hostname} domain=${template_domain} --- quiet
initrd /debian-installer/arm64/initrd.gz
}
menuentry 'Debian 13 arm64 rescue shell' --id debian-rescue {
linux /debian-installer/arm64/linux rescue/enable=true priority=low interface=auto hostname=rescue-${template_hostname} domain=${template_domain} ---
initrd /debian-installer/arm64/initrd.gz
}
menuentry 'Debian 13 arm64 expert/manual install' --id debian-expert {
linux /debian-installer/arm64/linux priority=low interface=auto hostname=manual-${template_hostname} domain=${template_domain} ---
initrd /debian-installer/arm64/initrd.gz
}
menuentry 'Debian 13 arm64 automated install, verbose' --id debian-auto-template-verbose {
linux /debian-installer/arm64/linux auto=true priority=critical url=${preseed_url} interface=auto hostname=${template_hostname} domain=${template_domain} ---
initrd /debian-installer/arm64/initrd.gz
}
menuentry 'UEFI firmware settings' --id uefi-firmware {
fwsetup
}
menuentry 'Exit PXE and continue local boot' --id localboot {
exit
}
menuentry 'Reboot' --id reboot {
reboot
}

View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab PXE Rescue Toolkit</title>
<style>
body {
margin: 0;
background: #0b1020;
color: #e5edf7;
font-family: Arial, sans-serif;
line-height: 1.55;
}
main {
width: min(980px, calc(100% - 32px));
margin: 0 auto;
padding: 32px 0 48px;
}
h1, h2 {
color: #ffffff;
}
.panel {
background: #11182c;
border: 1px solid #263551;
border-radius: 8px;
margin: 18px 0;
padding: 18px;
}
code {
background: #050816;
border: 1px solid #263551;
border-radius: 5px;
padding: 2px 6px;
}
a {
color: #7dd3fc;
}
</style>
</head>
<body>
<main>
<h1>Homelab PXE Rescue Toolkit</h1>
<p>This page is served by the Debian provisioning host at <code>${http_base_url}</code>. The same host serves TFTP boot assets from <code>${tftp_root}</code>.</p>
<section class="panel">
<h2>PXE Menu Entries</h2>
<ul>
<li><strong>Automated homelab worker template:</strong> unattended Debian install using <a href="${preseed_url}">preseed</a>.</li>
<li><strong>Rescue shell:</strong> Debian Installer rescue mode for mounting and repairing an installed system.</li>
<li><strong>Expert/manual install:</strong> interactive Debian Installer path for one-off machines.</li>
<li><strong>Automated install, verbose:</strong> same preseeded install without quiet boot.</li>
<li><strong>UEFI firmware settings:</strong> returns into firmware setup when supported.</li>
<li><strong>Exit PXE and continue local boot:</strong> leaves the GRUB netboot path.</li>
</ul>
</section>
<section class="panel">
<h2>Useful URLs</h2>
<ul>
<li><a href="/preseed/debian13-arm64-worker.cfg">/preseed/debian13-arm64-worker.cfg</a></li>
<li><a href="/scripts/golden-node-prepare.sh">/scripts/golden-node-prepare.sh</a></li>
<li><a href="/scripts/prepare-template.sh">/scripts/prepare-template.sh</a></li>
<li><a href="/debian-installer/arm64/linux">/debian-installer/arm64/linux</a></li>
<li><a href="/debian-installer/arm64/initrd.gz">/debian-installer/arm64/initrd.gz</a></li>
</ul>
</section>
<section class="panel">
<h2>Notes</h2>
<p>The default menu entry remains the automated install so the Pimox template builder can still boot unattended. Use the firmware console to select rescue or manual install when recovering a host.</p>
</section>
</main>
</body>
</html>

View File

@ -62,6 +62,16 @@ variable "pxe_boot_file" {
default = "grubaa64.efi"
}
variable "pxe_menu_timeout" {
type = number
default = 10
}
variable "pxe_default_menu_entry" {
type = number
default = 0
}
variable "template_hostname" {
type = string
default = "homelab-arm64-template"

View File

@ -1 +0,0 @@
main_script: jeannie

View File

@ -0,0 +1,140 @@
# Cluster Stop/Start Failures Runbook
Use this when `./jeannie stop-cluster` or `./jeannie start-cluster` does not
complete cleanly, RAM stays high after a stop, or the cluster does not recover
after a start.
## Intent
`stop-cluster` pauses Kubernetes without destroying kubeadm files, OpenTofu
state, PV data, or VM disks. It should keep host-level Docker Compose services
such as Gitea and RPi services separate from the Kubernetes runtime.
## Quick Triage
Run from the Debian host:
```bash
./jeannie status
systemctl is-active kubelet containerd docker
free -h
sudo crictl ps -a || true
kubectl get nodes -o wide || true
```
Check Pimox worker VM state:
```bash
ssh jv@192.168.100.80 'sudo qm list; sudo qm status 9010; sudo qm status 9011'
```
## Stop Failures
If Kubernetes containers remain after stop:
```bash
sudo systemctl stop kubelet
sudo crictl stop --all || true
sudo crictl rm --all || true
```
If RAM is still high, inspect what is actually using memory:
```bash
ps -eo pid,ppid,comm,%mem,rss,args --sort=-rss | head -40
sudo crictl ps -a || true
sudo docker ps
```
If the memory is from Kubernetes containers, remove stopped CRI containers:
```bash
sudo crictl rm --all || true
sudo crictl rmi --prune || true
```
If the memory is from Docker Compose services, do not stop Gitea or RPi DNS
unless that service is the incident. Those services intentionally stay online.
If worker VMs did not stop:
```bash
ssh jv@192.168.100.80 'sudo qm shutdown 9010 --timeout 120 || sudo qm stop 9010'
ssh jv@192.168.100.80 'sudo qm shutdown 9011 --timeout 120 || sudo qm stop 9011'
```
## Start Failures
Start the cluster runtime:
```bash
./jeannie start-cluster
```
If the API server does not come up:
```bash
sudo systemctl start containerd
sudo systemctl start kubelet
sudo journalctl -u kubelet -n 120 --no-pager
sudo crictl ps -a | head -50
```
If nodes stay `NotReady`:
```bash
kubectl get nodes -o wide
kubectl describe node debian | sed -n '/Conditions:/,/Addresses:/p'
kubectl -n kube-system get pods -o wide
```
Common causes:
- CNI pods are not running.
- containerd did not start on a worker.
- Pimox worker VMs are still stopped.
- Node clocks or DHCP addresses changed after restart.
Start Pimox workers manually if needed:
```bash
ssh jv@192.168.100.80 'sudo qm start 9010 || true; sudo qm start 9011 || true'
```
Then wait for nodes:
```bash
kubectl get nodes -w
```
## When To Rebuild Instead
Use rebuild only when start cannot recover a coherent cluster:
```bash
./jeannie rebuild-cluster
```
Prefer rebuild when:
- kubeadm state is inconsistent.
- kubelet versions drift from the control plane.
- worker VM disks/templates are known bad.
- repeated start attempts leave system pods permanently broken.
## Validation
```bash
kubectl get nodes -o wide
kubectl -n kube-system get pods -o wide
kubectl -n traefik get svc,pods -o wide
curl -I http://192.168.100.240/
./jeannie status
```
Expected:
- Control plane and desired workers are `Ready`.
- `kube-system` pods are running or completed.
- Traefik has the MetalLB IP.
- Host-level Gitea remains up throughout stop/start work.

View File

@ -0,0 +1,115 @@
# Edge Failures Runbook
Use this when `https://lab2025.duckdns.org/`, `/git/`, or a subdomain returns
`502 Bad Gateway`, times out, or serves the wrong backend.
## Topology
```text
internet -> OCI jump box nginx -> HAProxy/Varnish/Squid -> homelab backend
```
Current important backends:
- Website and Kubernetes apps: Traefik MetalLB IP `192.168.100.240:80`
- Gitea: Debian Tailscale IP `100.85.138.30:3000`
- OCI edge host: `132.145.170.74`
## Quick Triage
Run from the Debian homelab server:
```bash
./jeannie status
curl -I https://lab2025.duckdns.org/
curl -I https://lab2025.duckdns.org/git/
ssh ubuntu@132.145.170.74 'sudo docker compose -f /opt/homelab-edge/docker-compose.yml ps'
```
Check backend reachability from the OCI edge host:
```bash
ssh ubuntu@132.145.170.74 '
curl -I --max-time 5 http://192.168.100.240:80/ || true
curl -I --max-time 5 http://100.85.138.30:3000/ || true
tailscale status
'
```
## Interpret Results
`/git/` works but `/` is `502`:
- Gitea path over Tailscale is healthy.
- Traefik/MetalLB path is broken or the cluster is stopped.
- Check Kubernetes services and MetalLB assignment.
`/` works but `/git/` is `502`:
- Edge to Traefik is healthy.
- Gitea container, Debian Tailscale, or nginx `/git/` proxy path is broken.
- Use the Gitea runbook.
Both `/` and `/git/` are `502`:
- Edge containers may be unhealthy, DuckDNS may point somewhere unexpected, or
the OCI host may have lost Tailscale/private routing.
OCI can reach `100.85.138.30:3000` but public `/git/` is broken:
- Inspect `/opt/homelab-edge/default.conf`.
- The `/git/` location should proxy to Gitea without forwarding the `/git/`
prefix to the backend if Gitea is listening at `/`.
OCI cannot reach `192.168.100.240:80`:
- Check whether the cluster is intentionally stopped.
- Check Traefik service and MetalLB IP assignment.
- Check Tailscale subnet route acceptance on the OCI edge host.
## Recovery
Refresh the edge stack from the repo:
```bash
tofu -chdir=bootstrap/edge apply
```
Restart edge containers only:
```bash
ssh ubuntu@132.145.170.74 '
cd /opt/homelab-edge
sudo docker compose restart
sudo docker compose ps
'
```
Re-enable accepted Tailscale routes on the edge host:
```bash
ssh ubuntu@132.145.170.74 'sudo tailscale set --accept-routes=true'
```
If the cluster was intentionally stopped, either leave website paths down or
start the cluster:
```bash
./jeannie start-cluster
```
## Validation
```bash
curl -I https://lab2025.duckdns.org/
curl -I https://lab2025.duckdns.org/git/
ssh ubuntu@132.145.170.74 '
curl -I --max-time 5 http://192.168.100.240:80/ || true
curl -I --max-time 5 http://100.85.138.30:3000/ || true
'
```
Expected:
- Public requests return `200`, `301`, `302`, or app-specific `404`.
- Edge backend curls do not fail with connection refused or timeout.

View File

@ -0,0 +1,123 @@
# Gitea Failures Runbook
Use this when Gitea is down, `/git/` returns `502`, Git push/pull fails, login
does not work, or the pipeline cannot access the repository.
## Topology
Gitea is intentionally outside Kubernetes:
- Host: Debian `192.168.100.73`
- Tailscale IP: `100.85.138.30`
- Install directory: `/data/homelab-gitea`
- HTTP: `3000`
- SSH: `32222`
- Public path: `https://lab2025.duckdns.org/git/`
## Quick Triage
Run on the Debian host:
```bash
cd ~/my-homelab-configs
./jeannie status
cd /data/homelab-gitea
sudo docker compose ps
sudo docker compose logs --tail=120 gitea
curl -I http://127.0.0.1:3000/
tailscale ip -4
```
Check the edge path:
```bash
ssh ubuntu@132.145.170.74 'curl -I --max-time 5 http://100.85.138.30:3000/'
curl -I https://lab2025.duckdns.org/git/
```
Check Git SSH from Debian:
```bash
ssh -T -p 32222 git@192.168.100.73 || true
git remote -v
git ls-remote gitea main
```
## Common Cases
Container is stopped:
```bash
cd /data/homelab-gitea
sudo docker compose up -d
sudo docker compose ps
```
Docker or containerd is wedged:
```bash
sudo systemctl restart containerd
sudo systemctl restart docker
cd /data/homelab-gitea
sudo docker compose up -d
```
Gitea works locally but not from OCI edge:
```bash
tailscale ip -4
ssh ubuntu@132.145.170.74 'tailscale status; curl -I --max-time 5 http://100.85.138.30:3000/'
```
If the Debian Tailscale IP changed, update the edge variable and reapply:
```bash
tofu -chdir=bootstrap/edge apply
```
Git HTTPS prompts or fails authentication:
- Prefer SSH for writes from the homelab.
- Keep the Debian working copy remote pointed at the LAN SSH endpoint:
```bash
git remote set-url gitea ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git
git branch --set-upstream-to=gitea/main main
```
Login password is lost:
```bash
cd /data/homelab-gitea
sudo docker exec -it homelab-gitea gitea admin user list --config /data/gitea/conf/app.ini
sudo docker exec -it homelab-gitea gitea admin user change-password \
--config /data/gitea/conf/app.ini \
--username jv \
--password '<new-password>'
```
## Safe Redeploy
Redeploying Gitea should not delete data because the Compose stack uses the
persistent data directory under `/data/homelab-gitea/data`.
```bash
cd ~/my-homelab-configs
./jeannie deploy-gitea
./jeannie bootstrap-gitea-repo
```
## Validation
```bash
curl -I http://127.0.0.1:3000/
curl -I https://lab2025.duckdns.org/git/
git ls-remote gitea main
sudo docker compose -f /data/homelab-gitea/docker-compose.yml ps
```
Expected:
- Local HTTP returns a web response.
- Public `/git/` loads through edge.
- `git ls-remote` returns the `main` ref.

View File

@ -6,24 +6,29 @@ homelab server or in a deliberately scoped CI secret.
## First-Time Setup
Install the tools on the Debian host:
Restore/install the tools through the Debian host bootstrap:
```bash
sudo apt-get update
sudo apt-get install -y --no-install-recommends age sops
ansible-playbook -K -i bootstrap/host/inventory.ini bootstrap/host/playbook.yml
```
Generate the local age identity:
Then initialize the age identity and local SOPS config on the Debian host:
```bash
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
grep '^# public key:' ~/.config/sops/age/keys.txt
./jeannie secrets-init
```
Copy `.sops.yaml.example` to `.sops.yaml`, replace the placeholder recipient
with the printed public key, and commit `.sops.yaml`. The public recipient is
not sensitive; the private identity in `~/.config/sops/age/keys.txt` is.
`secrets-init` installs missing `age`/`sops` packages when needed, generates
`~/.config/sops/age/keys.txt` if it does not already exist, and writes
`.sops.yaml` from `.sops.yaml.example` with the public recipient from that key.
The public recipient in `.sops.yaml` is not sensitive and should be committed.
The private identity in `~/.config/sops/age/keys.txt` must stay outside Git.
Validate the current repo secret state:
```bash
./jeannie secrets-check
```
## File Naming
@ -54,3 +59,14 @@ SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops -d apps/example/app.secret.ya
Decrypted scratch files are intentionally ignored by `.gitignore`; encrypted
files are not.
## Git Rules
- Commit `.sops.yaml` after `./jeannie secrets-init` creates it.
- Commit only encrypted files matching `*.secret.yaml`, `*.enc.yaml`,
`*.secret.json`, or `*.enc.json`.
- Do not commit `~/.config/sops/age/keys.txt`, `.age-key.txt`, `sops-age.key`,
or decrypted scratch files such as `*.dec.yaml`, `*.decrypted.yaml`, and
`*.plain.yaml`.
- Run `./jeannie secrets-check` before pushing a change that adds or edits
encrypted secrets.

57
docs/service-catalog.md Normal file
View File

@ -0,0 +1,57 @@
# Homelab Service Catalog
<!-- Generated by scripts/render-service-catalog from homelab.yml. Do not edit by hand. -->
This catalog is the human-readable view of the non-secret homelab inventory.
Update `homelab.yml`, then run `scripts/render-service-catalog`.
## Hosts
| Host | Role | Address | Tailscale | Storage |
| --- | --- | --- | --- | --- |
| debian | control-plane | 192.168.100.73 | 100.85.138.30 | /data/docker |
| rpi4 | dns-edge-worker | 192.168.100.89 | 100.77.80.72 | /nvme-storage/docker |
| opi5_pimox | pimox | 192.168.100.80 | | opi5_ssd |
| oci_edge | public-edge | 132.145.170.74 | 100.118.255.19 | |
## Services
| Service | Host | Managed By | Local | Public | Docs |
| --- | --- | --- | --- | --- | --- |
| Website | Kubernetes | Argo CD / apps/website | [local](http://192.168.100.240/) | [public](https://lab2025.duckdns.org) | `apps/website/README.md` |
| Demo Apps | Kubernetes | Argo CD / apps/demos-static | [local](http://192.168.100.240/demo-apps/) | [public](https://demos.lab2025.duckdns.org/) | `apps/demos-static/` |
| Service Catalog | Kubernetes | Generated from homelab.yml | [local](http://192.168.100.240/demo-apps/homelab-catalog/) | [public](https://demos.lab2025.duckdns.org/homelab-catalog/) | `docs/service-catalog.md` |
| Gitea | debian (control-plane, 192.168.100.73) | Docker Compose / jeannie deploy-gitea | [local](http://192.168.100.73:3000/) | [public](https://lab2025.duckdns.org/git/) | `infra/gitea/README.md` |
| GitOps mirror | debian (control-plane, 192.168.100.73) | Bare Git repository | `/home/jv/git-server/my-homelab-configs.git` | [public](ssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.git) | `README.md#gitops-and-gitea-actions` |
| Container Registry | debian (control-plane, 192.168.100.73) | Argo CD / apps/container-registry | [local](http://192.168.100.73:30500/v2/_catalog) | | `apps/container-registry/` |
| Traefik | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://lab2025.duckdns.org) | `README.md#loadbalancer-services` |
| Pi-hole | rpi4 (dns-edge-worker, 192.168.100.89) | Docker Compose / jeannie rpi-services | [local](http://192.168.100.89:8081/admin/) | | `infra/rpi-services/README.md` |
| Unbound | rpi4 (dns-edge-worker, 192.168.100.89) | Docker Compose / jeannie rpi-services | `192.168.100.89:53` | | `infra/rpi-services/README.md` |
| Uptime Kuma | rpi4 (dns-edge-worker, 192.168.100.89) | Docker Compose / jeannie rpi-services | [local](http://192.168.100.89:3001/) | | `infra/rpi-services/README.md` |
| Provisioning PXE/HTTP | debian (control-plane, 192.168.100.73) | OpenTofu / bootstrap/provisioning | [local](http://192.168.100.73:8088/) | | `bootstrap/provisioning/README.md` |
| PXE Rescue Toolkit | debian (control-plane, 192.168.100.73) | OpenTofu / bootstrap/provisioning | [local](http://192.168.100.73:8088/rescue/) | | `bootstrap/provisioning/README.md` |
| Ollama | debian (control-plane, 192.168.100.73) | Local Debian service | [local](http://192.168.100.73:11434) | | `apps/website/ollama/README.md` |
| OCI Edge | oci_edge | OpenTofu / bootstrap/edge | `/opt/homelab-edge` | [public](https://lab2025.duckdns.org) | `docs/runbooks/edge-failures.md` |
| Heimdall | Kubernetes | Argo CD / apps/heimdall | [local](http://192.168.100.240/) | [public](https://heimdall.lab2025.duckdns.org/) | `apps/heimdall/` |
| n8n | Kubernetes | Argo CD / apps/n8n | [local](http://192.168.100.240/) | [public](https://n8n.lab2025.duckdns.org/) | `apps/n8n/` |
| Argo CD | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://argocd.lab2025.duckdns.org/) | `bootstrap/platform/` |
| Grafana | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://grafana.lab2025.duckdns.org/) | `bootstrap/platform/` |
| Prometheus | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://prometheus.lab2025.duckdns.org/) | `bootstrap/platform/` |
| Alertmanager | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://alertmanager.lab2025.duckdns.org/) | `bootstrap/platform/` |
| Prowlarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:9696/) | [public](https://prowlarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
| Sonarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:8989/) | [public](https://sonarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
| Radarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:7878/) | [public](https://radarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
| qBittorrent | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:8080/) | [public](https://qbittorrent.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
| Kapowarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:5656/) | [public](https://kapowarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
| Suwayomi | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:4567/) | [public](https://suwayomi.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
| Maintainerr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:6246/) | [public](https://maintainerr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
| FlareSolverr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:8191/) | [public](https://flaresolverr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` |
## Focused Checks
- `./jeannie status`
- `./jeannie preflight`
- `./jeannie doctor-edge`
- `./jeannie doctor-gitea`
- `./jeannie doctor-rpi`
- `./jeannie doctor-cluster`

126
homelab.yml Normal file
View File

@ -0,0 +1,126 @@
# Canonical non-secret homelab inventory.
# Keep hostnames, IPs, ports, and public names here before copying them into
# scripts, Terraform variables, runbooks, or service docs.
metadata:
main_script: jeannie
owner_user: jv
domain:
base: lab2025.duckdns.org
public_url: https://lab2025.duckdns.org
gitea_url: https://lab2025.duckdns.org/git/
subdomains:
- demos.lab2025.duckdns.org
- heimdall.lab2025.duckdns.org
- grafana.lab2025.duckdns.org
- prometheus.lab2025.duckdns.org
- alertmanager.lab2025.duckdns.org
- argocd.lab2025.duckdns.org
- n8n.lab2025.duckdns.org
- prowlarr.lab2025.duckdns.org
- sonarr.lab2025.duckdns.org
- radarr.lab2025.duckdns.org
- qbittorrent.lab2025.duckdns.org
- kapowarr.lab2025.duckdns.org
- suwayomi.lab2025.duckdns.org
- maintainerr.lab2025.duckdns.org
network:
lan_cidr: 192.168.100.0/24
lan_ip_prefix: 192.168.100.
metallb:
traefik_ip: 192.168.100.240
address_pool: 192.168.100.240-192.168.100.250
tailscale_cgnat_cidr: 100.64.0.0/10
hosts:
debian:
role: control-plane
user: jv
lan_ip: 192.168.100.73
tailscale_ip: 100.85.138.30
docker_root: /data/docker
data_root: /data
kubeconfig: /home/jv/.kube/config
rpi4:
role: dns-edge-worker
user: jv
lan_ip: 192.168.100.89
tailscale_ip: 100.77.80.72
docker_nvme_root: /nvme-storage/docker
docker_fallback_root: /var/lib/docker
opi5_pimox:
role: pimox
user: jv
lan_ip: 192.168.100.80
bridge: vmbr0
worker_storage: opi5_ssd
template_storage: local
oci_edge:
role: public-edge
user: ubuntu
public_ip: 132.145.170.74
tailscale_ip: 100.118.255.19
install_dir: /opt/homelab-edge
services:
gitea:
host: debian
install_dir: /data/homelab-gitea
http_port: 3000
ssh_port: 32222
root_url: https://lab2025.duckdns.org/git/
ssh_remote: ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git
gitops_mirror:
host: debian
path: /home/jv/git-server/my-homelab-configs.git
ssh_url: ssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.git
local_registry:
host: debian
endpoint: 192.168.100.73:30500
provisioning:
host: debian
http_port: 8088
preseed_url: http://192.168.100.73:8088/preseed/debian13-arm64-worker.cfg
rescue_url: http://192.168.100.73:8088/rescue/
ollama:
host: debian
url: http://192.168.100.73:11434
rpi_dns:
host: rpi4
pihole_dns_port: 53
pihole_web_port: 8081
uptime_kuma_port: 3001
unbound_container_port: 53
adlists_file: infra/rpi-services/adlists.txt
local_dns_records_file: infra/rpi-services/local-dns-records.txt
cname_records_file: infra/rpi-services/cname-records.txt
static_dhcp_hosts_file: infra/rpi-services/static-dhcp-hosts.txt
uptime_kuma_seed_file: infra/rpi-services/uptime-kuma-monitors.json
arr:
host: debian
prowlarr_port: 9696
radarr_port: 7878
sonarr_port: 8989
qbittorrent_port: 8080
kapowarr_port: 5656
suwayomi_port: 4567
maintainerr_port: 6246
flaresolverr_port: 8191
traefik:
host: kubernetes
load_balancer_ip: 192.168.100.240
http_port: 80
pimox:
template_vmid: 9000
worker_base_vmid: 9010
default_worker_count: 1
worker_name_prefix: pimox-worker
ai_gateway:
provider: ollama
enabled: true
url: http://192.168.100.73:11434
model: qwen2.5:0.5b
timeout_seconds: 20

View File

@ -0,0 +1,57 @@
# RPi Services
This stack runs lightweight always-on services on the Raspberry Pi outside the
Kubernetes cluster:
- Pi-hole on DNS port `53` and web port `8081`
- Unbound as Pi-hole's first upstream resolver
- Uptime Kuma on port `3001`
- Uptime Kuma monitor seeds for public edge, Gitea, Traefik, RPi DNS, Pi-hole,
and Debian-host ARR services
Pi-hole also keeps public fallback upstreams in its config, so DNS can continue
to resolve through `1.1.1.1` and `9.9.9.9` if the Unbound container stops.
The bootstrap script prefers Docker data under `/nvme-storage/docker` only when
that path is an active mount point and writable. If the NVMe is missing or
reset, it rewrites Docker's `data-root` to `/var/lib/docker` and restarts
Docker, allowing the services to come up from the SD/root filesystem instead.
Service config lives under `/opt/homelab-rpi-services/data` by default. Backup
storage is intentionally not placed on the NVMe until the disk has proven
stable.
Deploy from the Debian homelab server:
```bash
./jeannie rpi-services
```
Useful overrides:
```bash
LAB_RPI_HOST=192.168.100.89 ./jeannie rpi-services
PIHOLE_WEBPASSWORD='<password>' ./jeannie rpi-services
PIHOLE_WEB_PORT=8081 UPTIME_KUMA_PORT=3001 ./jeannie rpi-services
LAB_RPI_STOP_LEGACY_PIHOLE=false ./jeannie rpi-services
```
If `PIHOLE_WEBPASSWORD` is not provided, the bootstrap generates one and keeps
it in `/opt/homelab-rpi-services/.env` for future runs.
Managed Pi-hole state is stored in:
- `adlists.txt`
- `local-dns-records.txt`
- `cname-records.txt`
- `static-dhcp-hosts.txt`
The bootstrap inserts adlists without deleting existing manual Pi-hole lists. It
renders local DNS, CNAME, and optional static DHCP entries into
`/etc/dnsmasq.d/02-homelab-managed-dns.conf` inside the Pi-hole container, then
reloads DNS.
Managed Uptime Kuma monitors are stored in `uptime-kuma-monitors.json`. The
bootstrap seeds them after the Uptime Kuma database exists. If Uptime Kuma has
not completed initial admin setup yet, the seed step skips cleanly; rerun
`./jeannie rpi-services` after creating the first user.

View File

@ -0,0 +1,7 @@
# Managed Pi-hole adlists for the RPi4 DNS stack.
# Existing manual Pi-hole lists are not removed by the bootstrap script.
https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
https://big.oisd.nl/
https://nsfw.oisd.nl/
https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt
https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/tif.txt

321
infra/rpi-services/bootstrap.sh Executable file
View File

@ -0,0 +1,321 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="${LAB_RPI_SERVICES_INSTALL_DIR:-/opt/homelab-rpi-services}"
DATA_DIR="${LAB_RPI_SERVICES_DATA_DIR:-${INSTALL_DIR}/data}"
DOCKER_NVME_ROOT="${LAB_RPI_DOCKER_NVME_ROOT:-/nvme-storage/docker}"
DOCKER_FALLBACK_ROOT="${LAB_RPI_DOCKER_FALLBACK_ROOT:-/var/lib/docker}"
STOP_LEGACY_PIHOLE="${LAB_RPI_STOP_LEGACY_PIHOLE:-true}"
PIHOLE_WEBPASSWORD="${PIHOLE_WEBPASSWORD:-}"
install_missing_packages() {
local missing_packages=()
local package
for package in "$@"; do
if ! dpkg-query -W -f='${Status}' "${package}" 2>/dev/null | grep -q 'install ok installed'; then
missing_packages+=("${package}")
fi
done
if ((${#missing_packages[@]} > 0)); then
sudo apt-get update
sudo apt-get install -y --no-install-recommends "${missing_packages[@]}"
fi
}
docker_root_target() {
if mountpoint -q "${DOCKER_NVME_ROOT}"; then
sudo mkdir -p "${DOCKER_NVME_ROOT}"
if sudo test -w "${DOCKER_NVME_ROOT}"; then
printf '%s\n' "${DOCKER_NVME_ROOT}"
return 0
fi
fi
printf '%s\n' "${DOCKER_FALLBACK_ROOT}"
}
configure_docker_root() {
local target_root="$1"
local current_root=""
local configured_root=""
local daemon_file="/etc/docker/daemon.json"
sudo mkdir -p "${target_root}" /etc/docker
if sudo systemctl is-active --quiet docker; then
current_root="$(sudo docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
fi
if [[ -s "${daemon_file}" ]]; then
configured_root="$(python3 - "${daemon_file}" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as handle:
document = json.load(handle)
except Exception:
document = {}
print(document.get("data-root", ""))
PY
)"
fi
if [[ "${current_root}" == "${target_root}" && "${configured_root}" == "${target_root}" ]]; then
return 0
fi
echo "Configuring Docker data-root: ${target_root}"
sudo python3 - "${daemon_file}" "${target_root}" <<'PY'
import json
import os
import sys
path, target_root = sys.argv[1:3]
document = {}
if os.path.exists(path) and os.path.getsize(path) > 0:
with open(path, encoding="utf-8") as handle:
document = json.load(handle)
document["data-root"] = target_root
with open(path, "w", encoding="utf-8") as handle:
json.dump(document, handle, indent=2, sort_keys=True)
handle.write("\n")
PY
sudo systemctl restart docker
}
stop_legacy_pihole() {
local container
if [[ "${STOP_LEGACY_PIHOLE}" =~ ^(0|false|no|off|disabled)$ ]]; then
return 0
fi
for container in pihole pi-hole; do
if sudo docker ps -a --format '{{.Names}}' | grep -qx "${container}"; then
echo "Stopping legacy Pi-hole container ${container}; it is left on disk for manual rollback."
sudo docker stop "${container}" >/dev/null 2>&1 || true
fi
done
}
resolve_pihole_webpassword() {
local env_file="${INSTALL_DIR}/.env"
local existing_password=""
if [[ -n "${PIHOLE_WEBPASSWORD}" ]]; then
printf '%s\n' "${PIHOLE_WEBPASSWORD}"
return 0
fi
if [[ -s "${env_file}" ]]; then
existing_password="$(grep -E '^PIHOLE_WEBPASSWORD=' "${env_file}" | tail -n 1 | cut -d= -f2- || true)"
if [[ -n "${existing_password}" ]]; then
printf '%s\n' "${existing_password}"
return 0
fi
fi
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 18
return 0
fi
python3 - <<'PY'
import secrets
print(secrets.token_hex(18))
PY
}
write_env_file() {
PIHOLE_WEBPASSWORD="$(resolve_pihole_webpassword)"
sudo tee "${INSTALL_DIR}/.env" >/dev/null <<ENV_EOT
TZ=${TZ:-America/Mexico_City}
PIHOLE_IMAGE=${PIHOLE_IMAGE:-pihole/pihole:latest}
PIHOLE_CONTAINER_NAME=${PIHOLE_CONTAINER_NAME:-homelab-pihole}
PIHOLE_WEB_PORT=${PIHOLE_WEB_PORT:-8081}
PIHOLE_DNS_BIND_IP=${PIHOLE_DNS_BIND_IP:-0.0.0.0}
PIHOLE_WEB_BIND_IP=${PIHOLE_WEB_BIND_IP:-0.0.0.0}
PIHOLE_WEBPASSWORD=${PIHOLE_WEBPASSWORD}
PIHOLE_UPSTREAMS=${PIHOLE_UPSTREAMS:-unbound#53;1.1.1.1;9.9.9.9}
PIHOLE_DATA_DIR=${PIHOLE_DATA_DIR:-${DATA_DIR}/pihole/etc-pihole}
PIHOLE_DNSMASQ_DIR=${PIHOLE_DNSMASQ_DIR:-${DATA_DIR}/pihole/etc-dnsmasq.d}
PIHOLE_REV_SERVER=${PIHOLE_REV_SERVER:-false}
PIHOLE_REV_SERVER_TARGET=${PIHOLE_REV_SERVER_TARGET:-}
PIHOLE_REV_SERVER_DOMAIN=${PIHOLE_REV_SERVER_DOMAIN:-}
PIHOLE_REV_SERVER_CIDR=${PIHOLE_REV_SERVER_CIDR:-}
UNBOUND_IMAGE=${UNBOUND_IMAGE:-mvance/unbound-rpi:latest}
UNBOUND_CONTAINER_NAME=${UNBOUND_CONTAINER_NAME:-homelab-unbound}
UPTIME_KUMA_IMAGE=${UPTIME_KUMA_IMAGE:-louislam/uptime-kuma:1}
UPTIME_KUMA_CONTAINER_NAME=${UPTIME_KUMA_CONTAINER_NAME:-homelab-uptime-kuma}
UPTIME_KUMA_BIND_IP=${UPTIME_KUMA_BIND_IP:-0.0.0.0}
UPTIME_KUMA_PORT=${UPTIME_KUMA_PORT:-3001}
UPTIME_KUMA_DATA_DIR=${UPTIME_KUMA_DATA_DIR:-${DATA_DIR}/uptime-kuma}
RPI_SERVICES_NETWORK=${RPI_SERVICES_NETWORK:-homelab-rpi-services}
ENV_EOT
}
sync_pihole_adlists() {
local pihole_container="${PIHOLE_CONTAINER_NAME:-homelab-pihole}"
local adlists_file="${INSTALL_DIR}/adlists.txt"
local sql_file
local elapsed=0
if [[ ! -s "${adlists_file}" ]]; then
return 0
fi
until sudo docker exec "${pihole_container}" test -s /etc/pihole/gravity.db >/dev/null 2>&1; do
if ((elapsed >= 120)); then
echo "Timed out waiting for Pi-hole gravity database." >&2
return 1
fi
sleep 5
elapsed=$((elapsed + 5))
done
sql_file="$(mktemp)"
python3 - "${adlists_file}" >"${sql_file}" <<'PY'
import sys
adlists_file = sys.argv[1]
with open(adlists_file, encoding="utf-8") as handle:
addresses = [
line.strip()
for line in handle
if line.strip() and not line.lstrip().startswith("#")
]
if not addresses:
raise SystemExit(0)
def sql_string(value):
return "'" + value.replace("'", "''") + "'"
for address in addresses:
quoted = sql_string(address)
print(f"insert or ignore into adlist (address, enabled, comment) values ({quoted}, 1, 'homelab managed');")
print(f"update adlist set enabled = 1, comment = coalesce(nullif(comment, ''), 'homelab managed') where address = {quoted};")
PY
# shellcheck disable=SC2024
sudo docker exec -i "${pihole_container}" pihole-FTL sqlite3 /etc/pihole/gravity.db <"${sql_file}"
rm -f "${sql_file}"
sudo docker exec "${pihole_container}" pihole -g
}
sync_pihole_managed_dns() {
local pihole_container="${PIHOLE_CONTAINER_NAME:-homelab-pihole}"
local local_records_file="${INSTALL_DIR}/local-dns-records.txt"
local cname_records_file="${INSTALL_DIR}/cname-records.txt"
local static_dhcp_hosts_file="${INSTALL_DIR}/static-dhcp-hosts.txt"
local dnsmasq_file
dnsmasq_file="$(mktemp)"
python3 - "${local_records_file}" "${cname_records_file}" "${static_dhcp_hosts_file}" >"${dnsmasq_file}" <<'PY'
import sys
local_records_file, cname_records_file, static_dhcp_hosts_file = sys.argv[1:4]
print("# Generated by homelab rpi-services bootstrap. Edit repo files, not this file.")
def useful_lines(path):
try:
with open(path, encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if line and not line.startswith("#"):
yield line
except FileNotFoundError:
return
for line in useful_lines(local_records_file):
fields = line.split()
if len(fields) != 2:
raise SystemExit(f"Invalid local DNS record: {line}")
hostname, address = fields
print(f"address=/{hostname}/{address}")
for line in useful_lines(cname_records_file):
fields = line.split()
if len(fields) != 2:
raise SystemExit(f"Invalid CNAME record: {line}")
alias, target = fields
print(f"cname={alias},{target}")
for line in useful_lines(static_dhcp_hosts_file):
if not line.startswith("dhcp-host="):
raise SystemExit(f"Invalid static DHCP entry: {line}")
print(line)
PY
sudo docker cp "${dnsmasq_file}" "${pihole_container}:/etc/dnsmasq.d/02-homelab-managed-dns.conf"
rm -f "${dnsmasq_file}"
if ! sudo docker exec "${pihole_container}" pihole restartdns reload; then
sudo docker restart "${pihole_container}" >/dev/null
fi
}
seed_uptime_kuma_monitors() {
local uptime_kuma_container="${UPTIME_KUMA_CONTAINER_NAME:-homelab-uptime-kuma}"
local monitor_file="${INSTALL_DIR}/uptime-kuma-monitors.json"
local seed_script="${INSTALL_DIR}/seed-uptime-kuma.js"
local elapsed=0
if [[ ! -s "${monitor_file}" || ! -s "${seed_script}" ]]; then
echo "Uptime Kuma monitor seed files are missing; skipping monitor seed."
return 0
fi
until sudo docker exec "${uptime_kuma_container}" test -s /app/data/kuma.db >/dev/null 2>&1; do
if ((elapsed >= 180)); then
echo "Timed out waiting for Uptime Kuma database; skipping monitor seed." >&2
return 0
fi
sleep 5
elapsed=$((elapsed + 5))
done
sudo docker cp "${monitor_file}" "${uptime_kuma_container}:/tmp/homelab-kuma-monitors.json"
sudo docker cp "${seed_script}" "${uptime_kuma_container}:/tmp/homelab-seed-kuma.js"
if sudo docker exec -w /app "${uptime_kuma_container}" node /tmp/homelab-seed-kuma.js /tmp/homelab-kuma-monitors.json; then
sudo docker restart "${uptime_kuma_container}" >/dev/null
else
echo "Uptime Kuma monitor seed failed; leaving the running service unchanged." >&2
fi
}
install_missing_packages ca-certificates curl iptables python3 rsync
if ! command -v docker >/dev/null 2>&1; then
curl -fsSL https://get.docker.com | sudo sh
fi
sudo systemctl enable docker >/dev/null
configure_docker_root "$(docker_root_target)"
if ! sudo docker compose version >/dev/null 2>&1; then
install_missing_packages docker-compose-plugin
fi
sudo mkdir -p "${INSTALL_DIR}" "${DATA_DIR}/pihole/etc-pihole" "${DATA_DIR}/pihole/etc-dnsmasq.d" "${DATA_DIR}/uptime-kuma"
sudo cp "${SCRIPT_DIR}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
sudo cp "${SCRIPT_DIR}/adlists.txt" "${INSTALL_DIR}/adlists.txt"
sudo cp "${SCRIPT_DIR}/local-dns-records.txt" "${INSTALL_DIR}/local-dns-records.txt"
sudo cp "${SCRIPT_DIR}/cname-records.txt" "${INSTALL_DIR}/cname-records.txt"
sudo cp "${SCRIPT_DIR}/static-dhcp-hosts.txt" "${INSTALL_DIR}/static-dhcp-hosts.txt"
sudo cp "${SCRIPT_DIR}/uptime-kuma-monitors.json" "${INSTALL_DIR}/uptime-kuma-monitors.json"
sudo cp "${SCRIPT_DIR}/seed-uptime-kuma.js" "${INSTALL_DIR}/seed-uptime-kuma.js"
write_env_file
stop_legacy_pihole
cd "${INSTALL_DIR}"
sudo docker compose pull
sudo docker compose up -d --remove-orphans
sync_pihole_adlists
sync_pihole_managed_dns
seed_uptime_kuma_monitors
sudo docker compose ps

View File

@ -0,0 +1,5 @@
# Repo-managed Pi-hole CNAME records.
# Format: alias target
git.homelab.local gitea.homelab.local
pihole.homelab.local rpi4.homelab.local
uptime.homelab.local rpi4.homelab.local

View File

@ -0,0 +1,58 @@
services:
unbound:
image: ${UNBOUND_IMAGE:-mvance/unbound-rpi:latest}
container_name: ${UNBOUND_CONTAINER_NAME:-homelab-unbound}
restart: unless-stopped
networks:
dns:
aliases:
- unbound
pihole:
image: ${PIHOLE_IMAGE:-pihole/pihole:latest}
container_name: ${PIHOLE_CONTAINER_NAME:-homelab-pihole}
restart: unless-stopped
depends_on:
- unbound
ports:
- "${PIHOLE_DNS_BIND_IP:-0.0.0.0}:53:53/tcp"
- "${PIHOLE_DNS_BIND_IP:-0.0.0.0}:53:53/udp"
- "${PIHOLE_WEB_BIND_IP:-0.0.0.0}:${PIHOLE_WEB_PORT:-8081}:80/tcp"
environment:
TZ: ${TZ:-America/Mexico_City}
WEBPASSWORD: ${PIHOLE_WEBPASSWORD:-homelab-change-me}
DNSMASQ_LISTENING: all
PIHOLE_DNS_: ${PIHOLE_UPSTREAMS:-unbound#53;1.1.1.1;9.9.9.9}
FTLCONF_dns_upstreams: ${PIHOLE_UPSTREAMS:-unbound#53;1.1.1.1;9.9.9.9}
REV_SERVER: ${PIHOLE_REV_SERVER:-false}
REV_SERVER_TARGET: ${PIHOLE_REV_SERVER_TARGET:-}
REV_SERVER_DOMAIN: ${PIHOLE_REV_SERVER_DOMAIN:-}
REV_SERVER_CIDR: ${PIHOLE_REV_SERVER_CIDR:-}
volumes:
- ${PIHOLE_DATA_DIR:-/opt/homelab-rpi-services/data/pihole/etc-pihole}:/etc/pihole
- ${PIHOLE_DNSMASQ_DIR:-/opt/homelab-rpi-services/data/pihole/etc-dnsmasq.d}:/etc/dnsmasq.d
cap_add:
- NET_ADMIN
dns:
- 127.0.0.1
- 1.1.1.1
networks:
- dns
uptime-kuma:
image: ${UPTIME_KUMA_IMAGE:-louislam/uptime-kuma:1}
container_name: ${UPTIME_KUMA_CONTAINER_NAME:-homelab-uptime-kuma}
restart: unless-stopped
ports:
- "${UPTIME_KUMA_BIND_IP:-0.0.0.0}:${UPTIME_KUMA_PORT:-3001}:3001/tcp"
volumes:
- ${UPTIME_KUMA_DATA_DIR:-/opt/homelab-rpi-services/data/uptime-kuma}:/app/data
dns:
- 1.1.1.1
- 9.9.9.9
networks:
- dns
networks:
dns:
name: ${RPI_SERVICES_NETWORK:-homelab-rpi-services}

View File

@ -0,0 +1,7 @@
# Repo-managed Pi-hole local DNS records.
# Format: hostname ip-address
debian.homelab.local 192.168.100.73
gitea.homelab.local 192.168.100.73
rpi4.homelab.local 192.168.100.89
opi5-pimox.homelab.local 192.168.100.80
traefik.homelab.local 192.168.100.240

View File

@ -0,0 +1,174 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
function loadDatabaseModule() {
for (const moduleName of ["better-sqlite3", "/app/node_modules/better-sqlite3"]) {
try {
return require(moduleName);
} catch (_error) {
// Try the next location used by the upstream Uptime Kuma image.
}
}
throw new Error("Could not load better-sqlite3 from the Uptime Kuma container");
}
function nowSql() {
return new Date().toISOString().replace("T", " ").slice(0, 19);
}
function defaultForColumn(column, monitor, userId) {
const accepted = monitor.accepted_statuscodes || ["200-399"];
const defaults = {
name: monitor.name,
type: monitor.type,
active: 1,
user_id: userId,
interval: monitor.interval || 60,
retry_interval: monitor.retry_interval || 60,
resend_interval: monitor.resend_interval || 0,
maxretries: monitor.maxretries || 3,
accepted_statuscodes: JSON.stringify(accepted),
url: monitor.url || "",
method: monitor.method || "GET",
hostname: monitor.hostname || "",
port: monitor.port || null,
dns_resolve_type: monitor.dns_resolve_type || "A",
dns_resolve_server: monitor.dns_resolve_server || "1.1.1.1",
upside_down: 0,
weight: monitor.weight || 2000,
maxredirects: monitor.maxredirects || 10,
timeout: monitor.timeout || 48,
expiryNotification: 0,
ignoreTls: monitor.ignoreTls ? 1 : 0,
created_date: nowSql(),
modified_date: nowSql(),
body: "",
headers: "",
basic_auth_user: "",
basic_auth_pass: "",
authMethod: "",
mqtt_success_message: "",
database_connection_string: "",
database_query: "",
packet_size: 56
};
return Object.prototype.hasOwnProperty.call(defaults, column.name)
? defaults[column.name]
: "";
}
function monitorValues(monitor, columns, userId, includeCreatedDate) {
const values = {};
const supported = new Set(columns.map((column) => column.name));
const requested = {
name: monitor.name,
type: monitor.type,
active: 1,
user_id: userId,
interval: monitor.interval || 60,
retry_interval: monitor.retry_interval || 60,
resend_interval: monitor.resend_interval || 0,
maxretries: monitor.maxretries || 3,
accepted_statuscodes: JSON.stringify(monitor.accepted_statuscodes || ["200-399"]),
method: monitor.method || "GET",
url: monitor.url || "",
hostname: monitor.hostname || "",
port: monitor.port || null,
dns_resolve_type: monitor.dns_resolve_type || "A",
dns_resolve_server: monitor.dns_resolve_server || "1.1.1.1",
maxredirects: monitor.maxredirects || 10,
timeout: monitor.timeout || 48,
ignoreTls: monitor.ignoreTls ? 1 : 0,
modified_date: nowSql()
};
if (includeCreatedDate) {
requested.created_date = nowSql();
}
for (const [key, value] of Object.entries(requested)) {
if (supported.has(key)) {
values[key] = value;
}
}
for (const column of columns) {
if (column.pk || Object.prototype.hasOwnProperty.call(values, column.name)) {
continue;
}
if (column.notnull && column.dflt_value === null) {
values[column.name] = defaultForColumn(column, monitor, userId);
}
}
return values;
}
function upsertMonitor(database, monitor, columns, userId) {
const existing = database
.prepare("select id from monitor where user_id = ? and name = ? limit 1")
.get(userId, monitor.name);
if (existing) {
const values = monitorValues(monitor, columns, userId, false);
delete values.name;
delete values.user_id;
const assignments = Object.keys(values).map((key) => `"${key}" = @${key}`);
database
.prepare(`update monitor set ${assignments.join(", ")} where id = @id`)
.run({ ...values, id: existing.id });
console.log(`updated ${monitor.name}`);
return;
}
const values = monitorValues(monitor, columns, userId, true);
const keys = Object.keys(values);
database
.prepare(
`insert into monitor (${keys.map((key) => `"${key}"`).join(", ")}) values (${keys
.map((key) => `@${key}`)
.join(", ")})`
)
.run(values);
console.log(`created ${monitor.name}`);
}
function main() {
const monitorFile = process.argv[2] || "/tmp/homelab-kuma-monitors.json";
const databasePath = process.argv[3] || "/app/data/kuma.db";
const monitors = JSON.parse(fs.readFileSync(monitorFile, "utf8"));
const Database = loadDatabaseModule();
const database = new Database(databasePath);
const userTable = database
.prepare("select name from sqlite_master where type = 'table' and name = 'user'")
.get();
if (!userTable) {
console.log("Uptime Kuma user table is missing; skipping monitor seed");
return;
}
const user = database.prepare("select id from user order by id limit 1").get();
if (!user) {
console.log("Uptime Kuma initial setup has not been completed; skipping monitor seed");
return;
}
const columns = database.prepare("pragma table_info(monitor)").all();
if (!columns.length) {
throw new Error("Uptime Kuma monitor table is missing");
}
const transaction = database.transaction(() => {
for (const monitor of monitors) {
upsertMonitor(database, monitor, columns, user.id);
}
});
transaction();
}
main();

View File

@ -0,0 +1,3 @@
# Optional repo-managed dnsmasq DHCP host entries.
# Keep this file non-secret. Use one dnsmasq dhcp-host entry per line, for example:
# dhcp-host=AA:BB:CC:DD:EE:FF,192.168.100.10,my-host,infinite

View File

@ -0,0 +1,94 @@
[
{
"name": "Public Website",
"type": "http",
"url": "https://lab2025.duckdns.org/",
"accepted_statuscodes": ["200-399"]
},
{
"name": "Public Gitea",
"type": "http",
"url": "https://lab2025.duckdns.org/git/",
"accepted_statuscodes": ["200-399", "404"]
},
{
"name": "Traefik LAN",
"type": "http",
"url": "http://192.168.100.240/",
"accepted_statuscodes": ["200-399", "404"]
},
{
"name": "Gitea LAN",
"type": "http",
"url": "http://192.168.100.73:3000/",
"accepted_statuscodes": ["200-399", "404"]
},
{
"name": "Pi-hole Web",
"type": "http",
"url": "http://192.168.100.89:8081/admin/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "RPi DNS TCP 53",
"type": "port",
"hostname": "192.168.100.89",
"port": 53
},
{
"name": "RPi DNS UDP 53",
"type": "dns",
"hostname": "cloudflare.com",
"port": 53,
"dns_resolve_type": "A",
"dns_resolve_server": "192.168.100.89"
},
{
"name": "ARR Prowlarr",
"type": "http",
"url": "http://192.168.100.73:9696/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Radarr",
"type": "http",
"url": "http://192.168.100.73:7878/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Sonarr",
"type": "http",
"url": "http://192.168.100.73:8989/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR qBittorrent",
"type": "http",
"url": "http://192.168.100.73:8080/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Kapowarr",
"type": "http",
"url": "http://192.168.100.73:5656/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Suwayomi",
"type": "http",
"url": "http://192.168.100.73:4567/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Maintainerr",
"type": "http",
"url": "http://192.168.100.73:6246/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR FlareSolverr",
"type": "http",
"url": "http://192.168.100.73:8191/",
"accepted_statuscodes": ["200-399", "401", "403"]
}
]

39
infra/tailscale/README.md Normal file
View File

@ -0,0 +1,39 @@
# Tailscale ACLs
This directory stores the intended tailnet policy as code.
- `tailnet-policy.hujson` is the policy to review and apply in Tailscale.
- `scripts/validate-tailnet-policy` performs repo-local checks before you copy
or apply the policy.
The policy keeps the critical public edge path explicit:
```text
OCI edge -> Debian Gitea over Tailscale
OCI edge -> Traefik MetalLB IP through approved subnet route
tailnet members -> Pi-hole DNS and selected homelab admin ports
```
The OCI edge node must be tagged `tag:homelab-edge` in Tailscale before the
edge ACL is enforced. The tag owner is limited to tailnet admins.
## Validate
```bash
scripts/validate-tailnet-policy
```
The validator checks that the HUJSON parses after comment/trailing-comma
normalization and that host aliases match `homelab.yml`.
## Apply
Apply manually from the Tailscale admin console:
1. Open the tailnet ACL policy editor.
2. Paste `infra/tailscale/tailnet-policy.hujson`.
3. Use the preview/test flow in the console.
4. Save only after the preview matches the intended access.
Do not apply this blindly from automation until the Tailscale API token and
tailnet name are stored as SOPS-encrypted secrets.

View File

@ -0,0 +1,81 @@
// Homelab tailnet policy managed in this repository.
//
// Review in the Tailscale admin console or with the Tailscale API before
// applying. Device tags are optional until you tag machines in the console.
{
"tagOwners": {
"tag:homelab-admin": ["autogroup:admin"],
"tag:homelab-control-plane": ["autogroup:admin"],
"tag:homelab-rpi": ["autogroup:admin"],
"tag:homelab-edge": ["autogroup:admin"],
},
"hosts": {
"debian": "100.85.138.30",
"rpi4": "100.77.80.72",
"oci-edge": "100.118.255.19",
"traefik-lan": "192.168.100.240",
},
"acls": [
// Full access for tailnet admins.
{
"action": "accept",
"src": ["autogroup:admin"],
"dst": ["*:*"],
},
// Public edge path back into the homelab over Tailscale and approved
// subnet routes. Tag the OCI edge node with tag:homelab-edge before
// relying on this rule for lab2025.duckdns.org.
{
"action": "accept",
"src": ["tag:homelab-edge"],
"dst": [
"debian:3000",
"traefik-lan:80,443",
],
},
// Homelab members can reach normal operational surfaces.
{
"action": "accept",
"src": ["autogroup:member"],
"dst": [
"debian:22,3000,32222,30500,8088,11434",
"rpi4:22,53,8081,3001",
"oci-edge:22,80,443",
],
},
// DNS from tailnet clients to the RPi resolver.
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["rpi4:53"],
},
],
"ssh": [
{
"action": "accept",
"src": ["autogroup:admin"],
"dst": [
"tag:homelab-admin",
"tag:homelab-control-plane",
"tag:homelab-rpi",
"tag:homelab-edge",
],
"users": ["jv", "ubuntu", "root"],
},
],
"autoApprovers": {
"routes": {
"192.168.100.0/24": [
"tag:homelab-edge",
"tag:homelab-rpi",
],
},
},
}

916
jeannie

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONFIG_FILE="${REPO_ROOT}/docs.yml"
INVENTORY_FILE="${REPO_ROOT}/homelab.yml"
README_TEMPLATE="${REPO_ROOT}/README.md.tmpl"
README_OUTPUT="${REPO_ROOT}/README.md"
@ -18,24 +18,43 @@ Options:
USAGE
}
read_config_value() {
local key="$1"
read_inventory_value() {
local path="$1"
awk -F ':' -v key="${key}" '
$1 == key {
value = $0
sub("^[^:]*:[[:space:]]*", "", value)
gsub(/^[[:space:]"'"'"']+|[[:space:]"'"'"']+$/, "", value)
print value
found = 1
exit
}
END {
if (!found) {
exit 1
}
}
' "${CONFIG_FILE}"
python3 - "${INVENTORY_FILE}" "${path}" <<'PY'
import re
import sys
inventory_file, requested_path = sys.argv[1:3]
stack = []
values = {}
pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$")
with open(inventory_file, encoding="utf-8") as handle:
for raw_line in handle:
if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "):
continue
match = pattern.match(raw_line.rstrip("\n"))
if not match:
continue
indent = len(match.group(1))
key = match.group(2)
value = (match.group(3) or "").strip()
while stack and stack[-1][0] >= indent:
stack.pop()
current_path = ".".join([item[1] for item in stack] + [key])
if value == "":
stack.append((indent, key))
continue
if " #" in value:
value = value.split(" #", 1)[0].strip()
values[current_path] = value.strip("\"'")
value = values.get(requested_path)
if not value:
raise SystemExit(1)
print(value)
PY
}
render_readme() {
@ -71,8 +90,8 @@ main() {
;;
esac
if [[ ! -s "${CONFIG_FILE}" ]]; then
printf 'Missing docs config: %s\n' "${CONFIG_FILE}" >&2
if [[ ! -s "${INVENTORY_FILE}" ]]; then
printf 'Missing homelab inventory: %s\n' "${INVENTORY_FILE}" >&2
return 1
fi
if [[ ! -s "${README_TEMPLATE}" ]]; then
@ -80,9 +99,9 @@ main() {
return 1
fi
main_script="$(read_config_value main_script)"
main_script="$(read_inventory_value metadata.main_script)"
if [[ -z "${main_script}" ]]; then
printf 'docs.yml main_script must not be empty\n' >&2
printf 'homelab.yml metadata.main_script must not be empty\n' >&2
return 1
fi
@ -90,6 +109,7 @@ main() {
render_readme "${main_script}" >"${rendered}"
if [[ "${mode}" == "check" ]]; then
"${REPO_ROOT}/scripts/render-service-catalog" --check
if ! cmp -s "${rendered}" "${README_OUTPUT}"; then
printf 'Generated README.md is stale. Run scripts/render-docs.\n' >&2
diff -u "${README_OUTPUT}" "${rendered}" || true
@ -102,6 +122,7 @@ main() {
fi
mv "${rendered}" "${README_OUTPUT}"
"${REPO_ROOT}/scripts/render-service-catalog"
printf 'Rendered %s from %s\n' "${README_OUTPUT}" "${README_TEMPLATE}"
}

500
scripts/render-service-catalog Executable file
View File

@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""Render homelab service catalog artifacts from homelab.yml."""
from __future__ import annotations
import argparse
import html
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
INVENTORY = REPO_ROOT / "homelab.yml"
DOC_OUTPUT = REPO_ROOT / "docs" / "service-catalog.md"
HTML_OUTPUT = REPO_ROOT / "apps" / "demos-static" / "public" / "homelab-catalog" / "index.html"
def parse_inventory(path: pathlib.Path) -> dict[str, object]:
values: dict[str, object] = {}
stack: list[tuple[int, str]] = []
current_list_path: str | None = None
pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$")
with path.open(encoding="utf-8") as handle:
for raw_line in handle:
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("- "):
if current_list_path:
if not isinstance(values.get(current_list_path), list):
values[current_list_path] = []
values[current_list_path].append(stripped[2:].strip().strip("\"'"))
continue
match = pattern.match(raw_line.rstrip("\n"))
if not match:
current_list_path = None
continue
indent = len(match.group(1))
key = match.group(2)
value = (match.group(3) or "").strip()
while stack and stack[-1][0] >= indent:
stack.pop()
path_key = ".".join([item[1] for item in stack] + [key])
if value == "":
stack.append((indent, key))
values.setdefault(path_key, {})
current_list_path = path_key
continue
if " #" in value:
value = value.split(" #", 1)[0].strip()
values[path_key] = value.strip("\"'")
current_list_path = None
return values
def value(values: dict[str, object], path: str, default: str = "") -> str:
raw_value = values.get(path, default)
if isinstance(raw_value, list):
return ", ".join(str(item) for item in raw_value)
if isinstance(raw_value, dict):
return default
return str(raw_value)
def host_ip(values: dict[str, object], host_name: str) -> str:
if host_name == "kubernetes":
return value(values, "services.traefik.load_balancer_ip")
return value(values, f"hosts.{host_name}.lan_ip")
def host_label(values: dict[str, object], host_name: str) -> str:
if host_name == "kubernetes":
return "Kubernetes"
ip = host_ip(values, host_name)
role = value(values, f"hosts.{host_name}.role")
if ip and role:
return f"{host_name} ({role}, {ip})"
if ip:
return f"{host_name} ({ip})"
return host_name
def service_rows(values: dict[str, object]) -> list[dict[str, str]]:
domain = value(values, "domain.base")
public_url = value(values, "domain.public_url")
debian_ip = value(values, "hosts.debian.lan_ip")
rpi_ip = value(values, "hosts.rpi4.lan_ip")
traefik_ip = value(values, "network.metallb.traefik_ip")
registry = value(values, "services.local_registry.endpoint")
rows = [
{
"name": "Website",
"host": "kubernetes",
"managed_by": "Argo CD / apps/website",
"local": f"http://{traefik_ip}/",
"public": public_url,
"docs": "apps/website/README.md",
},
{
"name": "Demo Apps",
"host": "kubernetes",
"managed_by": "Argo CD / apps/demos-static",
"local": f"http://{traefik_ip}/demo-apps/",
"public": f"https://demos.{domain}/",
"docs": "apps/demos-static/",
},
{
"name": "Service Catalog",
"host": "kubernetes",
"managed_by": "Generated from homelab.yml",
"local": f"http://{traefik_ip}/demo-apps/homelab-catalog/",
"public": f"https://demos.{domain}/homelab-catalog/",
"docs": "docs/service-catalog.md",
},
{
"name": "Gitea",
"host": value(values, "services.gitea.host", "debian"),
"managed_by": "Docker Compose / jeannie deploy-gitea",
"local": f"http://{debian_ip}:{value(values, 'services.gitea.http_port', '3000')}/",
"public": value(values, "services.gitea.root_url"),
"docs": "infra/gitea/README.md",
},
{
"name": "GitOps mirror",
"host": value(values, "services.gitops_mirror.host", "debian"),
"managed_by": "Bare Git repository",
"local": value(values, "services.gitops_mirror.path"),
"public": value(values, "services.gitops_mirror.ssh_url"),
"docs": "README.md#gitops-and-gitea-actions",
},
{
"name": "Container Registry",
"host": value(values, "services.local_registry.host", "debian"),
"managed_by": "Argo CD / apps/container-registry",
"local": f"http://{registry}/v2/_catalog",
"public": "",
"docs": "apps/container-registry/",
},
{
"name": "Traefik",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": public_url,
"docs": "README.md#loadbalancer-services",
},
{
"name": "Pi-hole",
"host": "rpi4",
"managed_by": "Docker Compose / jeannie rpi-services",
"local": f"http://{rpi_ip}:{value(values, 'services.rpi_dns.pihole_web_port', '8081')}/admin/",
"public": "",
"docs": "infra/rpi-services/README.md",
},
{
"name": "Unbound",
"host": "rpi4",
"managed_by": "Docker Compose / jeannie rpi-services",
"local": f"{rpi_ip}:53",
"public": "",
"docs": "infra/rpi-services/README.md",
},
{
"name": "Uptime Kuma",
"host": "rpi4",
"managed_by": "Docker Compose / jeannie rpi-services",
"local": f"http://{rpi_ip}:{value(values, 'services.rpi_dns.uptime_kuma_port', '3001')}/",
"public": "",
"docs": "infra/rpi-services/README.md",
},
{
"name": "Provisioning PXE/HTTP",
"host": "debian",
"managed_by": "OpenTofu / bootstrap/provisioning",
"local": f"http://{debian_ip}:{value(values, 'services.provisioning.http_port', '8088')}/",
"public": "",
"docs": "bootstrap/provisioning/README.md",
},
{
"name": "PXE Rescue Toolkit",
"host": "debian",
"managed_by": "OpenTofu / bootstrap/provisioning",
"local": value(values, "services.provisioning.rescue_url"),
"public": "",
"docs": "bootstrap/provisioning/README.md",
},
{
"name": "Ollama",
"host": "debian",
"managed_by": "Local Debian service",
"local": value(values, "services.ollama.url"),
"public": "",
"docs": "apps/website/ollama/README.md",
},
{
"name": "OCI Edge",
"host": "oci_edge",
"managed_by": "OpenTofu / bootstrap/edge",
"local": value(values, "hosts.oci_edge.install_dir"),
"public": public_url,
"docs": "docs/runbooks/edge-failures.md",
},
{
"name": "Heimdall",
"host": "kubernetes",
"managed_by": "Argo CD / apps/heimdall",
"local": f"http://{traefik_ip}/",
"public": f"https://heimdall.{domain}/",
"docs": "apps/heimdall/",
},
{
"name": "n8n",
"host": "kubernetes",
"managed_by": "Argo CD / apps/n8n",
"local": f"http://{traefik_ip}/",
"public": f"https://n8n.{domain}/",
"docs": "apps/n8n/",
},
{
"name": "Argo CD",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://argocd.{domain}/",
"docs": "bootstrap/platform/",
},
{
"name": "Grafana",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://grafana.{domain}/",
"docs": "bootstrap/platform/",
},
{
"name": "Prometheus",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://prometheus.{domain}/",
"docs": "bootstrap/platform/",
},
{
"name": "Alertmanager",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://alertmanager.{domain}/",
"docs": "bootstrap/platform/",
},
]
for service_name in [
"prowlarr",
"sonarr",
"radarr",
"qbittorrent",
"kapowarr",
"suwayomi",
"maintainerr",
"flaresolverr",
]:
display_name = {
"qbittorrent": "qBittorrent",
"suwayomi": "Suwayomi",
"maintainerr": "Maintainerr",
"flaresolverr": "FlareSolverr",
}.get(service_name, service_name.capitalize())
port = value(values, f"services.arr.{service_name}_port")
rows.append(
{
"name": display_name,
"host": value(values, "services.arr.host", "debian"),
"managed_by": "Docker Compose / infra/arr-stack",
"local": f"http://{debian_ip}:{port}/" if port else "",
"public": f"https://{service_name}.{domain}/",
"docs": "infra/arr-stack/README.md",
}
)
return rows
def host_rows(values: dict[str, object]) -> list[dict[str, str]]:
rows = []
for host in ["debian", "rpi4", "opi5_pimox", "oci_edge"]:
rows.append(
{
"name": host,
"role": value(values, f"hosts.{host}.role"),
"lan": value(values, f"hosts.{host}.lan_ip") or value(values, f"hosts.{host}.public_ip"),
"tailscale": value(values, f"hosts.{host}.tailscale_ip"),
"storage": value(values, f"hosts.{host}.docker_root")
or value(values, f"hosts.{host}.docker_nvme_root")
or value(values, f"hosts.{host}.worker_storage"),
}
)
return rows
def markdown_table(headers: list[str], rows: list[list[str]]) -> str:
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
for row in rows:
lines.append("| " + " | ".join(cell.replace("\n", " ") for cell in row) + " |")
return "\n".join(lines)
def md_link(label: str, target: str) -> str:
if not target:
return ""
if target.startswith(("http://", "https://", "ssh://")):
return f"[{label}]({target})"
return f"`{target}`"
def render_markdown(values: dict[str, object]) -> str:
hosts = host_rows(values)
services = service_rows(values)
host_table = markdown_table(
["Host", "Role", "Address", "Tailscale", "Storage"],
[[row["name"], row["role"], row["lan"], row["tailscale"], row["storage"]] for row in hosts],
)
service_table = markdown_table(
["Service", "Host", "Managed By", "Local", "Public", "Docs"],
[
[
row["name"],
host_label(values, row["host"]),
row["managed_by"],
md_link("local", row["local"]),
md_link("public", row["public"]),
md_link("docs", row["docs"]),
]
for row in services
],
)
return f"""# Homelab Service Catalog
<!-- Generated by scripts/render-service-catalog from homelab.yml. Do not edit by hand. -->
This catalog is the human-readable view of the non-secret homelab inventory.
Update `homelab.yml`, then run `scripts/render-service-catalog`.
## Hosts
{host_table}
## Services
{service_table}
## Focused Checks
- `./jeannie status`
- `./jeannie preflight`
- `./jeannie doctor-edge`
- `./jeannie doctor-gitea`
- `./jeannie doctor-rpi`
- `./jeannie doctor-cluster`
"""
def html_link(target: str) -> str:
if not target:
return '<span class="muted">not exposed</span>'
escaped = html.escape(target)
if target.startswith(("http://", "https://", "ssh://")):
return f'<a href="{escaped}">{escaped}</a>'
return f"<code>{escaped}</code>"
def render_html(values: dict[str, object]) -> str:
hosts = host_rows(values)
services = service_rows(values)
host_cards = "\n".join(
f""" <article class="catalog-card service-host">
<span>{html.escape(row["role"])}</span>
<h2>{html.escape(row["name"])}</h2>
<p><strong>Address:</strong> {html.escape(row["lan"] or "n/a")}</p>
<p><strong>Tailscale:</strong> {html.escape(row["tailscale"] or "n/a")}</p>
<p><strong>Storage:</strong> {html.escape(row["storage"] or "n/a")}</p>
</article>"""
for row in hosts
)
service_rows_html = "\n".join(
f""" <tr>
<td>{html.escape(row["name"])}</td>
<td>{html.escape(host_label(values, row["host"]))}</td>
<td>{html.escape(row["managed_by"])}</td>
<td>{html_link(row["local"])}</td>
<td>{html_link(row["public"])}</td>
<td>{html_link(row["docs"])}</td>
</tr>"""
for row in services
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab Service Catalog</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body class="theme-dark">
<main class="shell catalog-shell">
<nav class="topline">
<a href="../">All demos</a>
<a href="/demos.php">Website catalog</a>
</nav>
<section class="hero catalog-hero">
<p class="kicker">Inventory view</p>
<h1>Homelab Service Catalog</h1>
<p>Generated from <code>homelab.yml</code>. Use this as the operational map for hosts, ports, public routes, local URLs, ownership, and runbooks.</p>
</section>
<section class="grid host-grid" aria-label="Homelab hosts">
{host_cards}
</section>
<section class="panel service-table-panel" aria-label="Homelab services">
<div class="table-scroll">
<table class="service-table">
<thead>
<tr>
<th>Service</th>
<th>Host</th>
<th>Managed by</th>
<th>Local</th>
<th>Public</th>
<th>Docs</th>
</tr>
</thead>
<tbody>
{service_rows_html}
</tbody>
</table>
</div>
</section>
<section class="panel command-panel">
<h2>Focused Checks</h2>
<p><code>./jeannie status</code> <code>./jeannie preflight</code> <code>./jeannie doctor-edge</code> <code>./jeannie doctor-gitea</code> <code>./jeannie doctor-rpi</code> <code>./jeannie doctor-cluster</code></p>
</section>
</main>
<script src="../theme.js"></script>
</body>
</html>
"""
def write_if_changed(path: pathlib.Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def check_file(path: pathlib.Path, content: str) -> bool:
if not path.exists():
return False
return path.read_text(encoding="utf-8") == content
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="fail if generated catalog artifacts are stale")
args = parser.parse_args()
values = parse_inventory(INVENTORY)
markdown = render_markdown(values)
catalog_html = render_html(values)
if args.check:
stale = []
if not check_file(DOC_OUTPUT, markdown):
stale.append(str(DOC_OUTPUT.relative_to(REPO_ROOT)))
if not check_file(HTML_OUTPUT, catalog_html):
stale.append(str(HTML_OUTPUT.relative_to(REPO_ROOT)))
if stale:
print("Generated service catalog artifacts are stale:", file=sys.stderr)
for path in stale:
print(f" {path}", file=sys.stderr)
return 1
print("service catalog is current")
return 0
write_if_changed(DOC_OUTPUT, markdown)
write_if_changed(HTML_OUTPUT, catalog_html)
print(f"Rendered {DOC_OUTPUT.relative_to(REPO_ROOT)}")
print(f"Rendered {HTML_OUTPUT.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

167
scripts/validate-tailnet-policy Executable file
View File

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Validate the repo-managed Tailscale policy without contacting Tailscale."""
from __future__ import annotations
import json
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
POLICY_PATH = REPO_ROOT / "infra" / "tailscale" / "tailnet-policy.hujson"
INVENTORY_PATH = REPO_ROOT / "homelab.yml"
def strip_hujson(source: str) -> str:
output: list[str] = []
index = 0
in_string = False
escaped = False
while index < len(source):
char = source[index]
next_char = source[index + 1] if index + 1 < len(source) else ""
if in_string:
output.append(char)
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
index += 1
continue
if char == '"':
in_string = True
output.append(char)
index += 1
continue
if char == "/" and next_char == "/":
index += 2
while index < len(source) and source[index] not in "\r\n":
index += 1
continue
if char == "/" and next_char == "*":
index += 2
while index + 1 < len(source) and source[index : index + 2] != "*/":
index += 1
index += 2
continue
output.append(char)
index += 1
normalized = "".join(output)
cleaned: list[str] = []
index = 0
in_string = False
escaped = False
while index < len(normalized):
char = normalized[index]
if in_string:
cleaned.append(char)
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
index += 1
continue
if char == '"':
in_string = True
cleaned.append(char)
index += 1
continue
if char == ",":
lookahead = index + 1
while lookahead < len(normalized) and normalized[lookahead].isspace():
lookahead += 1
if lookahead < len(normalized) and normalized[lookahead] in "]}":
index += 1
continue
cleaned.append(char)
index += 1
return "".join(cleaned)
def parse_simple_inventory(path: pathlib.Path) -> dict[str, str]:
values: dict[str, str] = {}
stack: list[tuple[int, str]] = []
pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$")
for raw_line in path.read_text(encoding="utf-8").splitlines():
if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "):
continue
match = pattern.match(raw_line)
if not match:
continue
indent = len(match.group(1))
key = match.group(2)
value = (match.group(3) or "").strip()
while stack and stack[-1][0] >= indent:
stack.pop()
path_key = ".".join([item[1] for item in stack] + [key])
if value == "":
stack.append((indent, key))
continue
if " #" in value:
value = value.split(" #", 1)[0].strip()
values[path_key] = value.strip("\"'")
return values
def require(condition: bool, message: str, failures: list[str]) -> None:
if not condition:
failures.append(message)
def main() -> int:
failures: list[str] = []
policy = json.loads(strip_hujson(POLICY_PATH.read_text(encoding="utf-8")))
inventory = parse_simple_inventory(INVENTORY_PATH)
require(isinstance(policy.get("hosts"), dict), "policy must define hosts", failures)
require(isinstance(policy.get("acls"), list) and policy["acls"], "policy must define non-empty acls", failures)
require(isinstance(policy.get("tagOwners"), dict), "policy must define tagOwners", failures)
hosts = policy.get("hosts", {})
if isinstance(hosts, dict):
expected_hosts = {
"debian": inventory.get("hosts.debian.tailscale_ip"),
"rpi4": inventory.get("hosts.rpi4.tailscale_ip"),
"oci-edge": inventory.get("hosts.oci_edge.tailscale_ip"),
"traefik-lan": inventory.get("network.metallb.traefik_ip"),
}
for name, expected_value in expected_hosts.items():
require(bool(expected_value), f"homelab.yml missing inventory value for {name}", failures)
require(hosts.get(name) == expected_value, f"policy host {name}={hosts.get(name)!r}, expected {expected_value!r}", failures)
for index, acl in enumerate(policy.get("acls", [])):
require(acl.get("action") == "accept", f"acl[{index}] action must be accept", failures)
require(isinstance(acl.get("src"), list) and acl["src"], f"acl[{index}] must have non-empty src list", failures)
require(isinstance(acl.get("dst"), list) and acl["dst"], f"acl[{index}] must have non-empty dst list", failures)
if failures:
print("tailnet policy validation failed:", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print("tailnet policy static validation passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())