Compare commits
11 Commits
84855cd927
...
8488c9523e
| Author | SHA1 | Date |
|---|---|---|
|
|
8488c9523e | |
|
|
acb6817c98 | |
|
|
f38b065599 | |
|
|
23dd03b0ef | |
|
|
e3a1c05a2e | |
|
|
e4b2057f15 | |
|
|
6070493b1b | |
|
|
7beb0778c9 | |
|
|
d5ff049cae | |
|
|
6ee2e2cb46 | |
|
|
534c87132e |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
133
README.md
|
|
@ -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
|
||||
|
|
|
|||
133
README.md.tmpl
133
README.md.tmpl
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue