Cyclops
Cyclops — Worker Node of the Dual-Node Fleet
Node: cyclops.painlesswp.com · Ubuntu 24.04 (AppArmor) · public
160.191.163.106Role: fleet worker — app services behind claw's ingress, reachable only over the fleet WireGuard tunnel (10.0.0.2). Managed by: SaltStack as a minion of claw's master (10.0.0.1). Source of truth is the salt-fleet repo (Forgejochorompotro/salt-fleet, GitHub mirrorfakeid30/salt-fleet) undercyclops/salt/minion/*.sls— staged to claw/srv/salt/minion/bytools/deploy.sh, applied withsudo /opt/saltvenv/bin/salt cyclops state.apply <minion.state>. Live as of 2026-09-14.
1. The model — two servers, ONE ingress
Internet → *.eazyplugins.com DNS → claw (160.250.95.39) Caddy TLS
→ Anubis (per-service, on claw) → 10.0.0.2:<port> over WireGuard → cyclops container
- Claw (AlmaLinux 10.2, SELinux enforcing) — sole public ingress. Caddy terminates TLS, Anubis bot-challenges, WireGuard hub (
10.0.0.1). Hosts the claw-local services and one Anubis instance per cyclops-backed service. - Cyclops (Ubuntu 24.04, AppArmor) — worker. No public ports beyond 22/80/443/2222 (Forgejo git-ssh). Services bind tunnel-only
10.0.0.2:<port>and are reached by claw's Caddy over WireGuard. ufw trusts the WG subnet wholesale. - WireGuard: claw
10.0.0.1↔ cyclops10.0.0.2; keys live in salt-fleet pillars (server:wireguard,minion:wireguard).
Why cyclops? Claw was RAM-bound; cyclops has headroom for stateful apps and their databases, and it moves heavier/untrusted workloads off the ingress box. Accepted trade-off: claw is a single point of failure for ingress — if claw is down, cyclops-hosted services are unreachable. Cyclops has no public path of its own, by design.
2. Service placement (current)
| Node | Services |
|---|---|
| Claw | SearXNG, Flame, Glances, Dozzle, PrivateBin, IT-Tools, BeaverHabits, Tandoor, Vikunja, Memos, Linkwarden, Paperless, LubeLogger, BookOrbit, Uptime Kuma, Hermes, YourSpotify, Jitsi, Radicale, TeamSpeak 6, RustDesk, FreeLLMAPI, Audiobookshelf, Cockpit, File Browser, Diun |
| Cyclops | Vaultwarden (10.0.0.2:8080), Outline (10.0.0.2:3011), KitchenOwl (10.0.0.2:3013), LibreSpeed speedtest (10.0.0.2:3012), Forgejo + runner (CI), OpenClaw (native process, loopback only) |
Every cyclops user-facing service is fronted by an Anubis instance on claw (host network, 127.0.0.1:89XX → 10.0.0.2:<port>):
| Service | Anubis on claw | cyclops WG backend | Policy |
|---|---|---|---|
| Vaultwarden | 127.0.0.1:8927 | 10.0.0.2:8080 | path-scoped — ALLOWs machine API, challenges web vault |
| Outline | 127.0.0.1:8920 | 10.0.0.2:3011 | default argon2id |
| KitchenOwl | 127.0.0.1:8926 | 10.0.0.2:3013 | path-scoped — ALLOWs /api/ (mobile apps) |
| LibreSpeed | 127.0.0.1:8929 | 10.0.0.2:3012 | path-scoped — ALLOWs /backend/ (measurement) |
3. Salt management (source of truth)
Cyclops is a salt minion (id cyclops) of claw's master (10.0.0.1, over WireGuard). Authoritative states live in the salt-fleet repo (~/salt-fleet on claw), directory cyclops/salt/minion/, applied via run0 /opt/saltvenv/bin/salt 'cyclops' state.apply <state>. Pushes hit Forgejo, pass the CI render gate, then deploy by webhook to claw /srv/salt + /srv/pillar.
Top-level state list (claw/salt/top.sls, 'cyclops' section) — this is the authoritative inventory:
| Salt state | What it manages |
|---|---|
minion.wireguard | wg0 config (/etc/wireguard/wg0.conf, 10.0.0.2/24), peer = claw 10.0.0.1:51820. Config-only: never watch→restart (wg-quick can't re-bring-up a live interface) |
minion.ufw | Host firewall: default deny incoming; allow 22, 80, 443, 2222 (forgejo git-ssh); allow 10.0.0.0/24 (salt, restic, cross-node) |
minion.salt-minion | Minion itself: venv /opt/saltvenv (salt 3008.2), /etc/salt/minion → master 10.0.0.1 |
minion.forgejo | Forgejo quadlet (codeberg.org/forgejo/forgejo:17), loopback 127.0.0.1:3026:3000 + git-ssh 0.0.0.0:2222 |
minion.anubis-cyclops | Anubis instance in front of Forgejo (anubis-forgejo). anubis-cyclops (openclaw front) is UNMANAGED — openclaw excluded per UNMANAGED.md |
minion.forgejo-runner | Forgejo Actions runner (docker mode) — builds this docs site; config salt-managed (runner-config.yml), labels: docker, salt-ci (prebaked salt image, see tools/salt-ci.Dockerfile in salt-fleet), ubuntu-latest (catthehacker act-latest) |
minion.caddy-cyclops | Cyclops-local Caddy /etc/caddy/Caddyfile (3 vhosts, see §8) |
minion.vaultwarden | Vaultwarden quadlet, no anubis (machine clients), WG-only 10.0.0.2:8080 |
minion.speedtest | LibreSpeed quadlet, WG-only 10.0.0.2:3012 (stateless) |
minion.kitchenowl | KitchenOwl web+back quadlets, WG-only 10.0.0.2:3013 (migrated from claw 2026-09-07) |
minion.outline | Outline app + postgres + redis quadlets, WG-only 10.0.0.2:3011 |
minion.backup | Nightly restic script (/usr/local/bin/restic-backup-cyclops.sh, root) |
minion.restic-verify | Sudoers entry: claw's weekly restic verify drill reads cyclops' repo pw |
Apply pattern (from claw): run0 /opt/saltvenv/bin/salt 'cyclops' state.apply minion.<state> --timeout=180
Secrets live in claw pillar claw/pillar/minion.sls under minion: (per-service blocks) + restic:. Never committed to the homelab docs repo.
⚠️
~/saltstackon cyclops is a STALE leftover from the pre-fleet standalone era (beaverhabits/privatebin/searxng/your_spotify states — those services do NOT run on cyclops anymore). Not used since minion mode was adopted. Do not apply anything from it.
4. Running containers (live inventory)
| Container | Image | Published | Network | Data dir |
|---|---|---|---|---|
| forgejo | codeberg.org/forgejo/forgejo:17 | 127.0.0.1:3026→3000; 0.0.0.0:2222→22 | forgejo_default | ~/forgejo/data |
| anubis-forgejo | ghcr.io/techarohq/anubis:v1.28.1 | (local) → 127.0.0.1:3026 | — | — |
| vaultwarden | docker.io/vaultwarden/server:latest | 10.0.0.2:8080→80 | — | ~/vaultwarden/data |
| outline-app | docker.io/outlinewiki/outline:1.10 | 10.0.0.2:3011→3000 | outline-net | ~/outline/data |
| outline-postgres | docker.io/library/postgres:17-alpine | — | outline-net | ~/outline/postgres |
| outline-redis | docker.io/library/redis:7-alpine | — | outline-net | — |
| speedtest | ghcr.io/librespeed/speedtest:latest | 10.0.0.2:3012→8080 | — | stateless |
| kitchenowl-web | docker.io/tombursch/kitchenowl-web:latest | 10.0.0.2:3013→80 | kitchenowl-net | ~/kitchenowl-app/data |
| kitchenowl-back | docker.io/tombursch/kitchenowl-backend:latest | — | kitchenowl-net | (same) |
| anubis-cyclops | ghcr.io/techarohq/anubis:v1.28.1 | (openclaw front, UNMANAGED) | — | — |
Quadlet files: ~/.config/containers/systemd/*.container (rootless podman; managed via systemctl --user as nirjhor — salt runs as root and cannot see user units directly).
Podman networks: forgejo_default, outline-net, kitchenowl-net (+ transient CI job networks).
5. WireGuard (fleet tunnel)
- Interface
wg0, address 10.0.0.2/24; peer = claw (10.0.0.1:51820), keepalive 25s. - Config
/etc/wireguard/wg0.confis salt-managed (minion.wireguard, mode 600, private key from pillarminion:wireguard). - The interface is brought up once at boot by
systemd; config changes need a manualsudo wg syncconf wg0(orsystemctl restart wg-quick@wg0only when the tunnel is down). Do not watch→restart from salt. - Carries: salt (master 10.0.0.1), restic SFTP leg, claw Caddy →
10.0.0.2app ports, forgejo-runner callbacks.
6. Firewall (ufw)
minion.ufw — default deny incoming, allow outgoing. Explicit allows:
22/tcpssh80/tcp+443/tcpcyclops-local Caddy (docs/forgejo/openclaw vhosts)2222/tcpforgejo git-sshfrom 10.0.0.0/24— full fleet trust (salt, restic sftp, cross-node)- App ports (3000/3001/9091/9092/39325/45189) stay bound but are public-inaccessible by default-deny.
7. Backups (restic)
Nightly 04:00 root timer (restic-backup.timer), script /usr/local/bin/restic-backup-cyclops.sh (salt-managed via minion.backup).
- Two repos (3-home principle):
sftp:nirjhor@10.0.0.1:/home/nirjhor/restic-repos/cyclops(claw disk)b2:claw-cyclops:/cyclops(Backblaze B2)
- PATHS:
~/forgejo/data,~/vaultwarden/data,~/outline,~/kitchenowl-app,~/.config/containers - Retention: keep-daily 7, keep-weekly 5, keep-monthly 6, prune.
- Verify: weekly restore drill Sunday 09:30 driven from claw (
restic-verify), reports → fleet-status repo.
8. Cyclops-local Caddy (/etc/caddy/Caddyfile, salt-managed minion.caddy-cyclops)
| Vhost | Backend |
|---|---|
cyclops.painlesswp.com | openclaw gateway (127.0.0.1:18789, RFC1918 bypass) / anubis-cyclops |
forgejo.cyclops.painlesswp.com | anubis-forgejo (:3001) with RFC1918 bypass → 127.0.0.1:3026 |
docs.cyclops.painlesswp.com | static /home/nirjhor/forgejo-pages/homelab (this site) |
This docs site = Zola build of the homelab repo, published by Forgejo Actions (publish-docs workflow, .forgejo/workflows/docs.yml) on every push to main → output /mnt_pages/homelab (bind-mounted ~/forgejo-pages/homelab), served by Caddy. To update these pages: edit ~/homelab on claw and push to Forgejo (git push forgejo main), not by editing ~/forgejo-pages directly. Site rendering (Zola 0.23.6 + Linkita theme, 2026-09-13 revision, both vendored/pinned — see below): code blocks are syntax-highlighted via [markdown.highlighting] in config.toml; mermaid diagrams render from GitHub-style fences on pages generated by tools/sync-site-content.sh (it rewrites fences to Zola <mermaid> components — canonical repo files keep raw fences for GitHub/gist). On manually-authored pages, write diagrams directly as Zola mermaid component invocations.
Toolchain pin — update these as a PAIR. Zola is pinned in .forgejo/workflows/docs.yml (the curl line that installs the binary) and the Linkita theme is vendored at themes/linkita/ (a plain copy, not a submodule, with no local patches — replacing it wholesale is safe). The two release in lockstep: Linkita's 2026-09-12 revision is "updates for Zola 0.23.5" and its 2026-09-13 revision is "updates for Zola 0.23.6", and Zola 0.23.6's own changelog entry exists to ship the Tera change that enables the theme's switch to component implicit params. Upgrading one without the other can leave the theme on component call signatures the Zola binary no longer accepts. Procedure: bump the CI pin → refresh themes/linkita/ from upstream master → bash tools/sync-site-content.sh → zola build → diff the output against the previous build (page count, then text-extract each page and confirm the prose is byte-identical). The 0.23.4→0.23.6 paired update on 2026-09-23 changed exactly one visible string site-wide (footer "Powered by Zola" → "Made with Zola") and nothing else.
This page is generated: cyclops/README.md → content/cyclops/index.md, alongside the claw pages (claw/README.md, claw/config.md, claw/guide.md, claw/CHANGELOG.md). Edit the repo-root file, never the generated copy.
9. Maintenance playbook (handover)
Add/update a service on cyclops (cross-node pattern):
- Write/extend
cyclops/salt/minion/<name>.slsin~/salt-fleet(quadlet + data dir + network,PublishPort=10.0.0.2:<port>:<cport>for tunnel-only apps). - Add the state to
'cyclops'list inclaw/salt/top.sls. - Add any secrets to
claw/pillar/minion.sls(minion:<name>:...) and the claw-side anubis entry (host_port,network: null,target: http://10.0.0.2:<port>) toclaw/pillar/server.sls. - Commit + push salt-fleet → Forgejo webhook auto-deploys (stages to
/srv/salt,/srv/pillaron claw). run0 /opt/saltvenv/bin/salt 'cyclops' state.apply minion.<name> --timeout=180- Claw side:
run0 /opt/saltvenv/bin/salt-call --local state.apply server.anubisthen restart the anubis instance (run0 systemctl --user -M nirjhor@ restart anubis-<name>.service);server.caddyapplies the vhost. - Verify: public vhost 200/302/401; app over WG answers; add policy file in
claw/salt/anubis/for/api/+/assets/surfaces (bookorbit/kitchenowl lesson).
Moving a service claw → cyclops (the kitchenowl recipe, 2026-09-07):
- Write the cyclops state
cyclops/salt/minion/<svc>.slsmirroringkitchenowl.sls/outline.sls/vaultwarden.sls: data dir (owned by the right uid), a dedicated podman network (<svc>-net), quadlet(s) publishing10.0.0.2:<port>only, secrets fromminion:<svc>pillar, restic PATHS += data dir. - Add to the cyclops branch of
claw/salt/top.sls; add the JWT/secret block toclaw/pillar/minion.sls(cyclops-only pillar). - Claw side: delete the old stack from
stacks.sls, flip the anubis pillar entry to{network: null, target: http://10.0.0.2:<port>, policy: <existing>}and the Caddy vhost toimport anubis 10.0.0.2:<port> 127.0.0.1:<89XX>. - Migrate data before starting cyclops containers: stop the claw services (systemd stop),
PRAGMA wal_checkpoint(TRUNCATE)for SQLite, tar+scp the data dir to cyclops. (Careful: extract into the exact target dir —tar xzffrom~putsdata/at~/data.) - Apply
minion.<svc>on cyclops first; verify data reads (sqlite3counts / API); THEN flip claw anubis + caddy. Rollback = keep old claw quadlets stopped, not deleted, until the new node is verified. - Doc-sync: config.md tables + README mermaid + guide vhost table + RUNBOOK (counts, DR dirs, setup row) in the same commit wave.
Drift check: claw runs drift-check daily 05:37 → /var/log/drift-report.txt; cyclops states must read 0 failures.
Auto-update: podman-auto-update.timer daily 00:15 (user), AutoUpdate=registry on quadlets.
openclaw gateway: native user service on cyclops (:18789) — excluded from all salt states (UNMANAGED.md). Update with ~/openclaw-safe-update.sh [version|latest], never openclaw update — the built-in updater's staged swap is broken on this host (see §10). Never via salt.
10. OpenClaw (native process — UNMANAGED by salt)
Systemd user service openclaw-gateway.service (node, loopback :18789), fronted by anubis-cyclops via cyclops.painlesswp.com. State under ~/.openclaw on cyclops. Current version 2026.9.4 (3a9d69d).
Updating. Use ~/openclaw-safe-update.sh [version|latest]. The built-in openclaw update failed six consecutive attempts (2026-09-13) with Failed: global install swap — Exit code: 1 … Installation recovery is unverified, leaving the gateway failed and the bot offline — even though the candidate itself passed all seven validation checks (global update, migration rehearsal, doctor lint, config validation, plugin resolution, migration continuation, gateway canary). Two compounding causes:
- Stop timeout too short. The unit allowed 30s to stop while the gateway legitimately drains active work for up to 315s, so systemd SIGKILLed it mid-swap. Fixed by the drop-in
~/.config/systemd/user/openclaw-gateway.service.d/stop-timeout.conf→TimeoutStopSec=360. Verify withsystemctl --user show openclaw-gateway -p TimeoutStopUSec(expect6min); a healthy stop shows noState 'stop-sigterm' timed outin the journal. - npm project manifest. npm is invoked with cwd=$HOME and
--allow-scriptsrequires a valid projectpackage.json;~/package.jsonhad nonamefield, so npm aborted withCannot destructure property 'name' of '.for'. Fixed (backup~/package.json.bak-20260913); installs now run from the scratch project~/.openclaw/npm-project.
The wrapper: stops the gateway (clean drain) → tars the tree to ~/backups/ → npm-installs from the scratch project with the full --allow-scripts list → openclaw plugins update --all → restarts → verifies HTTP 200 and version → auto-rolls back from the tarball on failure. A ~3-minute drain on stop is normal, not a hang.
Provider policy: zai-only. Only zai is enabled, plus ollama for embeddings (memory search and dreaming depend on it); 19 other provider plugins are disabled. Default model pinned to agents.defaults.model.primary = zai/glm-5.3-flash, fallbacks glm-5.2 → glm-5.1 → glm-5-turbo. agents.defaults.modelPolicy.allow lists zai models only, so anything outside it cannot be selected. Dreaming is pinned to a zai model with allowModelOverride. Note: a provider absent from plugins.entries is still enabled by default — disabling needs an explicit entry.
Source of truth: Forgejo chorompotro/openclaw-cyclops (private). Contains sanitized config (secrets replaced by ${...} placeholders), workspace and memory notes, non-bundled skills, this systemd wiring (drop-in nesting preserved), the safe-update scripts, and CLI-derived exports (cron/plugins/skills/models/agents). Working copy ~/openclaw-cyclops on cyclops; openclaw-cyclops-sync.timer pushes every 15 minutes (idempotent). Restore with scripts/bootstrap.sh --dry-run then bootstrap.sh. Bulk runtime state — agents/*.sqlite, state/openclaw.sqlite, memory embeddings, caches, media — is deliberately not in the repo; restore it from restic if needed.
Monitoring gap (not implemented). No monitoring agent runs on cyclops; dozzle/glances/uptime-kuma/diun are claw-only, so cyclops is covered by external checks at best. Options scoped: Uptime Kuma push/HTTP monitors (agent-less), a Dozzle agent container, Glances server mode, or a second Diun instance on cyclops.
11. Known gotchas (read before touching)
- Salt runs as root → can't see user systemd units. Drive rootless units via
run0 systemctl --user -M nirjhor@ …(orsudo -u nirjhor XDG_RUNTIME_DIR=/run/user/1000 systemctl --user …). Cyclops salt states usecmd.run → sudo -u nirjhor env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus systemctl --user …; on claw interactive shells the equivalent isrun0 systemctl --user -M nirjhor@ …. - No SELinux — AppArmor. Omit
:z/:Zfrom mounts;file.directoryuid strings work the same, but the package module is apt. - Rootless podman uid map differs from claw. Cyclops
/etc/subuidisnirjhor:100000:65536(claw is524288). Container postgres uid 70 → host100069on cyclops; container root → host 1000. Compute from the live subuid, never copy claw's numbers. - Outline postgres data dir ownership: must be host-uid
100069(container postgres uid 70 mapped through rootless100000:65536). Never chown tonirjhor— the container then fails withPermission deniedonpg_filenode.map. - No passwordless sudo for the agent SSH key on cyclops — root-file checks via
run0 /opt/saltvenv/bin/salt 'cyclops' cmd.run '…'. - Vaultwarden intentionally has NO anubis (browser extension / mobile / CLI clients can't solve challenges) — app-level master-password auth only.
- Forgejo app.ini lives inside
~/forgejo/data(GITEA_CUSTOM) — user-managed, not salt-owned; salt only ensures the quadlet + data-dir existence (never prune). - KitchenOwl data is a live SQLite (WAL mode) under
~/kitchenowl-app/data— restic-covered; don't copy while hot withoutPRAGMA wal_checkpoint(TRUNCATE)first. - uWSGI/app IPv6 binds: an app binding
[::]:portmay not accept IPv4-mapped connections on netavark the way it does on claw — test through the actual front (nginxuwsgi_pass, Caddy) rather than raw HTTP curls to the app port (kitchenowl-back speaks the uwsgi protocol on :5000, not HTTP). - Slow-VM timers:
podman-auto-update.timerand the 04:00 restic system timer both run here — verify after any change (slow-VM 30s health-check misses are a known failure mode). openclaw updateis broken on this host. The staged swap aborts (Installation recovery is unverified, exit 1) and leaves the gatewayfailed— the bot goes offline. Update with~/openclaw-safe-update.shinstead; see §10 for the two root causes and the recovery procedure.
12. Repos
- salt-fleet — all salt states + pillar (source of truth). Push → Forgejo → CI render gate → webhook deploy to claw
/srv/salt. - homelab — docs (this file,
claw/*.md,content/) + Forgejo CI builds the docs site. - fleet-status — machine-generated restic verify reports.
- openclaw-cyclops — OpenClaw config/workspace/skills/wiring for this node, auto-synced every 15 min (see §10).
- Gists (
167988534e…,13c6d2d3a0…) are migrated/frozen — never update, never delete.