diff --git a/CLAUDE.md b/CLAUDE.md index 6e03021..b516f5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,13 +43,13 @@ Copy `_template/` to the new name, fill in its README, and add one line to the r ## The environment -> **⚠ Confirm the gaps marked `?` below, then delete this warning.** The infrastructure -> facts are carried over from the FamilySync project, where they are documented and -> verified. Only the mapping of the *name* `odin` to that host is assumed — the name -> appears in no memory, transcript, or config on this box. +**odin** — the Unraid server; the single host everything self-hosted runs on. Reached from +this box over **Tailscale** (`100.101.253.105`, LAN `192.168.90.103`). -**odin** — the Unraid server; the single host everything self-hosted runs on. Verified -characteristics: +> **See [docs/odin-access.md](docs/odin-access.md)** for the access path, the WebGUI/API +> endpoint, TLS caveats, and the Unraid GraphQL API — all verified from this box. + +Verified characteristics: | Aspect | Detail | | --- | --- | @@ -60,9 +60,12 @@ characteristics: | Auth | Authelia — OIDC/OAuth2 provider for internal apps | | Ingress | Pangolin/Newt tunnel — **no open inbound ports** | | DNS | Split-DNS on `bergerhouse.net`; private IPs resolve internally | -| Git forge | Self-hosted Gitea/Forgejo at `git.bergerhouse.net` (user `luckberg`) | +| Git forge | Self-hosted Gitea/Forgejo at `git.bergerhouse.net` (user `luckberg`, SSH port 222) | | CI | Gitea Actions with a self-hosted runner | | Mail / calendar | Fastmail (paid) — JMAP/CalDAV, the source of truth for calendars | +| Remote access | Tailscale; odin `100.101.253.105`, subnet route for `192.168.90.0/24` | +| Unraid API | GraphQL at `{myunraid.net host}:8443/graphql`, `x-api-key` auth (Unraid 7.2+) | +| Automation | n8n on odin — drives the Docker-manager Telegram bot | Known service hostnames: `git.bergerhouse.net`, `familysync.bergerhouse.net`, `familysync-dev.bergerhouse.net`. @@ -72,14 +75,26 @@ PostgreSQL, any cloud provider. ### This box vs. odin -The machine this repo is edited on is `dev` — a **separate workstation**, not the server. -Scripts written here usually *target* odin (over SSH, or by being deployed into a -container/user-script on it). Never assume a script runs on the same host it was authored -on: take the target as a parameter or read it from config. +The machine this repo is edited on is `dev` (Tailscale `100.94.16.46`) — a **separate +workstation**, and in fact a VPS with a public IP, not a machine on the home LAN. Scripts +written here usually *target* odin. Never assume a script runs on the same host it was +authored on: take the target as a parameter or read it from config. -`?` — SSH access method to odin (no `~/.ssh/config` entry exists on this box yet), and +Tailscale is the **only** path from `dev` to odin — if it is down, nothing below resolves. + +`?` — still unconfirmed: the SSH login to odin (no `~/.ssh/config` entry on this box) and where deployed scripts are expected to live on the Unraid host. +### Sibling repos on `git.bergerhouse.net` + +`luckberg/` holds `scripts` (this repo), `familysync`, `unraid-docker-manager`, +`azure-epac`, `_cargo-index`. Two are load-bearing context: + +- **`unraid-docker-manager`** — n8n + Telegram bot managing odin's containers. The + authoritative source on the Unraid GraphQL API and container polling; distilled into + [docs/odin-access.md](docs/odin-access.md). +- **`familysync`** — the origin of most of the stack facts in the table above. + --- ## Prior context: MemPalace diff --git a/README.md b/README.md index 746ce53..ca19142 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Docker Compose behind Authelia and a Pangolin/Newt tunnel). Each script collection lives in its own top-level folder and is fully self-contained — its own README, config, and dependencies. Collections never import from each other. -See [CLAUDE.md](CLAUDE.md) for the environment details and repo conventions. +See [CLAUDE.md](CLAUDE.md) for the environment details and repo conventions, and +[docs/odin-access.md](docs/odin-access.md) for how to reach odin and drive its API. ## Collections diff --git a/docs/odin-access.md b/docs/odin-access.md new file mode 100644 index 0000000..108af82 --- /dev/null +++ b/docs/odin-access.md @@ -0,0 +1,149 @@ +# Accessing odin + +Reference for reaching the Unraid host (**odin**) from this workstation (`dev`) and +driving its API. Everything here was verified from `dev` on 2026-08-15; the "Verified" +column says how. + +Related repo: **`luckberg/unraid-docker-manager`** on `git.bergerhouse.net` — an n8n +Telegram bot that manages odin's containers. It is the origin of most of the API detail +below and is worth reading before writing anything new against the Unraid API. + +--- + +## Network path: Tailscale + +`dev` and odin are both on the tailnet. `dev` is a **VPS** (public IP `51.222.234.162`), +*not* a machine on the home LAN — Tailscale is the only path to odin. + +| Node | Tailscale IP | Notes | +| --- | --- | --- | +| `dev` | `100.94.16.46` | this workstation (linux) | +| `odin` | `100.101.253.105` | the Unraid host (linux) | + +Other tailnet nodes seen: `duplicati`, `experience` (windows), `fedora`, `nusgwvm`, +`salt`, `google-pixel-8-pro`. + +**A subnet route is advertised for the home LAN.** odin's LAN address `192.168.90.103` is +reachable from `dev` and routes over `tailscale0`: + +``` +$ ip route get 192.168.90.103 +192.168.90.103 dev tailscale0 table 52 src 100.94.16.46 +``` + +Both the tailnet IP and the LAN IP answer at ~34 ms RTT — the same path. This matters: +the `myunraid.net` URL below looks like a cloud relay, but **DNS resolves it to the +private `192.168.90.103`**, so traffic goes direct over Tailscale and never leaves the +tailnet. If Tailscale is down, none of it is reachable. + +--- + +## WebGUI / API endpoint + +``` +https://192-168-90-103.87c90a69c53e7197560c778a63f483e675130f2a.myunraid.net:8443/ +``` + +The hostname encodes odin's LAN IP with dashes, under a per-server hash on `myunraid.net`. +This is the **canonical vhost** — not merely a convenience alias. Use it for everything. + +### Why the plain LAN IP does not work + +Both failure modes are real and confirmed: + +| Attempt | Result | Verified | +| --- | --- | --- | +| `https://192.168.90.103:8443/graphql` | **404** from nginx | curl | +| `http://192.168.90.103/graphql` | **302** → the `myunraid.net` URL | curl | +| `https://:8443/graphql` | **200**, GraphQL responds | curl | + +nginx only serves `/graphql` on the `myunraid.net` `server_name`; the raw IP vhost returns +404. Plain HTTP redirects to that hostname — and **the redirect strips the `x-api-key` +header**, so a client that follows redirects authenticates as nobody. Always call the +`myunraid.net` URL directly rather than relying on a redirect. + +### TLS caveat — correcting the other repo + +`unraid-docker-manager` states "Valid certs via myunraid.net — no SSL ignore needed." +**That is not true from `dev`.** Verification fails here: + +``` +depth=1 C=US, O=Let's Encrypt, CN=YR1 +verify error:num=20:unable to get local issuer certificate +depth=0 CN=*.87c90a69c53e7197560c778a63f483e675130f2a.myunraid.net +``` + +The leaf is a genuine Let's Encrypt wildcard, but the chain presents an intermediate +(`YR1`) that this box's CA bundle (`ca-certificates 20250419`) cannot chain to a trusted +root — either the server omits the intermediate or the bundle predates it. The cert is not +forged; the chain is incomplete *for this client*. + +Practical consequence: **`curl` needs `-k` from `dev`**, and any script that talks to odin +must either pass the insecure flag or pin/supply the issuer. n8n running *on* odin may +well verify fine, which is likely why the other repo's claim held there. Prefer supplying +the missing intermediate over making `-k` a permanent habit — `-k` disables verification +entirely, which on a tailnet is a tolerable but real weakening. + +--- + +## Unraid GraphQL API + +- **Endpoint:** `{UNRAID_HOST}/graphql`, POST, where `UNRAID_HOST` is the `myunraid.net` + base URL **without** the `/graphql` suffix. +- **Auth:** `x-api-key: ` header. +- **Availability:** native in Unraid 7.2+ (odin is on 7.2); 6.9–7.1 needs the Connect plugin. + +Unauthenticated requests return HTTP **200** with a GraphQL error body — not an HTTP 401: + +```json +{"errors":[{"message":"Invalid CSRF token","extensions":{"code":"UNAUTHENTICATED", + "originalError":{"error":"Unauthorized","statusCode":401}}}],"data":null} +``` + +**Never treat HTTP 200 as success.** Always inspect `response.errors[]`. + +### Polling running containers + +```bash +curl -sS -k -X POST "${UNRAID_HOST}/graphql" \ + -H 'Content-Type: application/json' \ + -H "x-api-key: ${UNRAID_API_KEY}" \ + -d '{"query":"query { docker { containers { id names state } } }"}' +``` + +```json +{"data":{"docker":{"containers":[ + {"id":":","names":["/n8n"],"state":"RUNNING"} +]}}} +``` + +Field behaviours that bite: + +- `state` is **UPPERCASE** — `RUNNING`, not `running`. +- `names` is an array and entries are **`/`-prefixed** — `/n8n`, not `n8n`. +- `id` is a `PrefixedID`: `{server_hash}:{container_hash}`, two 64-char SHA-256 hex + strings joined by a colon. The server half is identical for every container on odin. +- **`isUpdateAvailable` does not exist** in the 7.2 schema, despite appearing in that + repo's earlier research. Introspect before relying on any field. + +### API keys + +Not present on this box — `.env.unraid-api` is gitignored and absent from the clone. Create +a key scoped to what the script actually needs: + +```bash +unraid-api apikey --create --name "" --permissions "DOCKER:UPDATE_ANY" --json +``` + +Or WebGUI → Settings → Management Access → API Keys. Store it in the collection's own +`.env`, never in git. + +--- + +## Gotchas carried over from `unraid-docker-manager` + +- **Each Bash tool call is a fresh shell.** Source the env file in the *same* command + chain as the request: `. .env.unraid-api; curl ...`. A separate `source` call is lost. +- **Large API responses:** save to a temp file before parsing; piping a 400 KB body + straight into `python3 -c` fails silently. +- Docker API `204 No Content` means success with an empty body.