One webhook per route, from a list, so adding a route is an entry rather
than a copy of the unit. The PR-review hook subscribes
pull_request_review_comment and pull_request_review_rejected.
The unit runs as the gitea user on a multi-user box, where
/proc/<pid>/cmdline is world-readable for the lifetime of the process, so
`-H "Authorization: token $t"` published the admin token and
`jq --arg secret "$s"` the webhook secret -- which is exactly what the
existing comment claimed to be avoiding by putting the body on stdin. The
token now goes through a 0600 `curl -K` config written with printf (a
shell builtin, so the substitution never reaches an argv) and the secret
through jq --rawfile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
A timeline comment on a PR never reached the route. Gitea reuses the same
strings in two namespaces and they collide:
subscription name wire name (X-GitHub-Event) what it is
pull_request_comment issue_comment comment on a PR
issue_comment issue_comment comment on an issue
pull_request_review_comment pull_request_comment review on a PR
The hook's `events` array takes the subscription name; Hermes matches
--events against X-GitHub-Event, the wire name, produced by
HookEventType.Event() in modules/webhook/type.go. So --events
pull_request_comment was selecting review submissions and could never match a
comment -- the exact inversion of what it reads like.
That also explains both observed failures. The review submission matched
(wire name pull_request_comment) and reached the filter, which correctly
dropped it on action=reviewed since a PullRequestPayload carries no comment
object. The timeline comment arrived as issue_comment, matched nothing, and
was dropped by the events filter before the script ever ran.
gitea.nix and hermes-agent.nix now deliberately name the same event
differently, so both carry the table and say the other is not a typo.
issue_comment on the wire also covers comments on plain issues. The hook does
not subscribe those, and the filter's is_pull check drops them regardless, so
widening the hook later cannot leak issue comments into the agent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Names the webhook "PR comments Hermes" (gitea's CreateHookOption/EditHookOption
both carry an optional `name`, so it survives the create and the update path)
and narrows it from all 26 event types to pull_request_comment alone.
Gitea sends pull_request_comment distinctly from issue_comment, so the hook
now covers comments on pull requests and nothing else. Hermes would have
dropped the rest anyway -- its route filters on X-GitHub-Event before any LLM
call -- so this is defence in depth rather than the only gate, but it keeps
traffic that can never be acted on from crossing the wire and reaching the
agent's process at all.
The tradeoff is that event selection now lives on both sides: a second Hermes
route needs its event adding here as well as being subscribed. That is the
right way round for a single-purpose hook, and the comment says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
The relay existed on the premise that Gitea sends no header Hermes can read
an event name from, so something had to copy X-Gitea-Event into
X-GitHub-Event. That premise was wrong. Gitea's addDefaultHeaders sets
req.Header["X-GitHub-Delivery"] = []string{t.UUID}
req.Header["X-GitHub-Event"] = []string{event}
req.Header["X-GitHub-Event-Type"] = []string{eventType}
unconditionally, for every webhook type, alongside X-Hub-Signature-256 in
GitHub's exact format. (Direct map assignment rather than .Add() specifically
to keep the "GitHub" casing that canonicalisation would destroy.) Hermes
validates that signature on any route without provider gating and reads the
event name from that header, so gitea and hermes already speak the same
protocol and the translation layer was translating nothing.
Gitea now posts straight at http://mars.orbit.sol:8644/webhooks/gitea-pr-comments.
The URL path is the Hermes route name, so a second subscription is a second
hook and nothing else -- the route-in-path indirection the relay grew was a
reimplementation of something Hermes already had.
Removes the module, the 200-line relay, its test, the mars import, the 8645
listener, and the stale gitea-hermes-webhook-relay.service entry left in the
secret's restartUnits. hermes-agent-webhook-route moves to
hosts/mars/hermes-agent.nix, next to the container and the read-only prompt
and filter mounts it depends on.
Also makes that unit refuse to subscribe when GITEA_HERMES_WEBHOOK_SECRET is
unset in the container, matching the existing empty-prompt check. An empty
secret silently fails every delivery signature check afterwards while the
unit still reports success -- the worst possible failure shape, and one this
setup can actually produce on a first deploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Mirrors the `hermes` alias on mars. The admin CLI is effectively
undiscoverable without it: the package is not in systemPackages so `gitea` is
not on PATH at all, every admin subcommand needs GITEA_WORK_DIR pointed at a
stateDir that is not the module default, and it has to run as the gitea user
or it drops root-owned files into that directory. Getting any of the three
wrong fails in a different and unhelpful way.
Both the package path and the stateDir come from the config rather than being
written out, so a gitea bump or a stateDir move cannot leave the alias
pointing at something stale — which is exactly what a hardcoded /nix/store
path would do.
Lives in services/dev/gitea.nix, which only jupiter imports, so it does not
leak onto hosts with no gitea to administer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Webhook delivery to the hermes relay was refused outright:
Post "http://mars.orbit.sol:8645/gitea/gitea-pr-comments":
dial tcp 100.64.0.6:8645: webhook can only call allowed HTTP servers
(check your security.ALLOWED_HOST_LIST setting),
deny 'mars.orbit.sol(100.64.0.6:8645)'
ALLOWED_HOST_LIST defaults to `external`, documented as "a valid non-private
unicast IP". Tailscale addresses come from 100.64.0.0/10 — RFC 6598
carrier-grade NAT space — which is not RFC1918 private but does not satisfy
gitea's notion of external either, so every tailnet target is denied by
default. Nothing about the relay or the URL was wrong; the request never left
jupiter.
Sets the tailnet CIDR explicitly and keeps `external`, so a future webhook to
a public service still works without another edit here.
Goes in [security], not [webhook]: the webhook-section key is deprecated in
favour of this one and now merely falls back to it, and [security] is the
name the delivery error itself reports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Retiring gitea-events is a one-off migration, not something worth re-running
on every boot. Both units now only touch what they own: jupiter's creates or
updates its own hook and deletes nothing, and mars's removes only the route
it is about to re-subscribe, as the idempotency step for `subscribe`.
Keeping the deletes would have meant a redeploy could silently remove a hook
or route someone added deliberately -- a real risk now that sibling hooks
for other Hermes routes are the intended pattern.
README carries the manual commands, and the note that both hooks fire until
the old one is removed by hand, so events arrive twice in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Renames the subscription to gitea-pr-comments (it handles one event; the old
gitea-events name promised more than it delivered) and drops --deliver.
Rather than move the hardcoded route from one constant to another, the relay
now reads it from the request path: POST /gitea/<route> forwards to
<base>/webhooks/<route>. The route name was the last thing tying this service
to a specific subscription, so a second Hermes route is now a `hermes webhook
subscribe <name>` plus a Gitea hook at /gitea/<name>, with no relay change --
previously it would also have needed a second relay URL baked in here.
The path segment is interpolated into an outbound URL, so it is validated
against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ and refused rather than sanitised
when it does not match. The path is matched raw and never URL-decoded, so
percent-encoded separators fail the charset check instead of surviving it;
requiring an alphanumeric first character also rejects "." and "..". Without
this, POST /gitea/..%2fadmin would let anything that can reach the relay
steer it at other Hermes endpoints. Tests cover traversal, encoded traversal,
embedded slashes, leading dot/dash, and the length bound, and assert nothing
reaches the stub Hermes in any of those cases.
Dropping --deliver leaves it at its default of `log`. The prompt tells her to
answer in the pull request, so the PR comment is the delivery and a Telegram
copy would only duplicate it; this also removes the hardcoded chat id that
was a third copy of TELEGRAM_HOME_CHANNEL.
Provisioning retires the pre-rename hook by its EXACT old URL rather than by
"points at the relay". Now that sibling hooks for other routes are the
intended pattern, a prefix match would delete them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Completes the subscription: it had a secret, a delivery target and a script,
but no prompt and no event list, so it woke the agent on every forwarded
event with nothing to tell her what to do.
--events pull_request_comment narrows the route to the one event the prompt
handles. This only works because the relay copies X-Gitea-Event into
X-GitHub-Event; without that every delivery arrives as "unknown" and matches
nothing. Gitea sends pull_request_comment distinctly from issue_comment, so
plain issue comments no longer reach the agent at all. The Gitea-side hook
still posts the full event set to the relay and Hermes drops the rest before
any LLM call.
The prompt lives in hosts/mars/gitea-pr-comment-prompt.md, mounted read-only
next to the filter, and is read with $(cat) at subscribe time rather than
passed inline. That is not only about escaping: the text has to survive nix
`` string escaping, the systemd unit file, and `podman exec sh -c '...'`
single-quoting. It contains an apostrophe ("the PR's head branch") that
would terminate that single-quoted string early. Read from a file at runtime
the content never passes through shell source, so it can contain anything.
Verified end to end against the rendered unit with stubbed podman/hermes:
the value reaching --prompt is byte-identical to the repo file apart from
the trailing newline that command substitution strips.
`set -eu` inside the container shell is load-bearing. Without it a missing
prompt file makes cat fail, the substitution yields "", and the subscription
is created with an empty prompt -- a silent failure that still looks like a
healthy unit.
On what read-only does not buy: it protects the sources, and this unit
re-subscribes from them on every start, so a restart restores the intended
prompt, filter and events. The live subscription itself lives in
webhook_subscriptions.json under /opt/data and is hot-reloaded, which is
inside the agent's own write-safe root -- a self-modification would stick
until this unit next runs.
The prompt keeps its own stop conditions even though the filter already drops
those deliveries, and says explicitly that reaching them means the filter
failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
The gitea-events subscription woke the agent on every delivery. That is an
unbounded loop as soon as she is given a prompt that tells her to answer on
the PR: her answer is itself a pull_request_comment, which wakes her again.
Adds a Hermes route script that drops the deliveries that must never reach an
LLM call: luna's own comments (the loop guard), "deleted" actions (the body
is still in the payload, so acting on one means acting on a request that was
explicitly withdrawn), non-pull-request comments, empty bodies, and edits
that did not actually change the body — a label or attachment change fires
"edited" too. Everything else passes through unchanged.
Mounted READ-ONLY from the nix store rather than written into hermesHome.
Hermes resolves route scripts under ~/.hermes/scripts, which here is inside
/opt/data — HERMES_WRITE_SAFE_ROOT — so a filter written there would be a
loop guard sitting in the writable root of the agent it constrains. Deleting
it fails closed (Hermes treats a missing script as "ignore"), but rewriting
it to always-allow would silently restore the loop. Read-only from the store
makes that impossible and keeps the guard in git.
The script also normalises changes.body.from to always exist. Gitea omits
`changes` entirely on created events, and Hermes replaces the prompt payload
with whatever JSON the script emits, so guaranteeing the key here means a
prompt referencing {changes.body.from} renders empty instead of leaving an
unfilled placeholder.
Note the stdout contract (gateway/platforms/webhook.py): only exactly
"[SILENT]", empty output, or a nonzero exit drop a delivery. Any OTHER text
on stdout lets it through and is attached as script_output — so a stray
debug print would silently defeat the filter. All diagnostics go to stderr,
and gitea-pr-comment-filter-test.py asserts that discipline along with each
drop rule (25 cases). Run it after any edit: the fail-closed behaviour means
a syntax error produces silence, not an error.
--events is still unset; event selection remains runtime-tunable policy.
The filter covers only what must not be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Three fixes to how luna's git/tea credentials are set up on mars, all found
against the running instance on 2026-08-23.
Drop the host-side clone. hermes-agent-prepare-dirs used to clone this repo
into ${hermesHome}/workspace/homelab, but nothing ever told luna at runtime
that it was there — she self-manages config/profiles/memories, so a path
baked into this file never reached her. She searched /opt/data/homelab and
/workspace, found neither, and concluded she had no repo at all. The
credentials are what actually grant access; any checkout is hers to make
anywhere inside HERMES_WRITE_SAFE_ROOT. The stale directory left by the old
version is deliberately not cleaned up, just unmanaged from here on.
Point credential.helper at the CONTAINER's path. It was written as the host
path (${hermesHome}/.git-credentials), which does not exist inside the
container where git actually reads the config — broken this way from 3c1f3e5
until now. Nothing host-side consumes those credentials any more, so the
container's view is the only one that has to be right; added `containerHome`
to make the distinction explicit at the point of use.
Chown what the oneshot writes. The image's cont-init only chowns the top
level of hermesHome and its own state — it does not recurse into the
root-owned 0600 files this unit drops there (.git-credentials, and tea's
config.yml, which tea also writes 0600), even though it runs afterwards. The
symptom was not an error but an absence: git reported no credential helper
and tea no login. Uses `if` rather than `[ -d x ] && chown` because under
`set -e` a false test on the left of an && list aborts the unit.
gitea.nix carries the matching comment updates: the luna provisioning unit is
server-side only, and her token needs write:repository,write:issue,read:user.
write:issue is the one that is easy to miss — a pull request IS an issue in
gitea's data model, so /pulls endpoints gate on the issue scope category and
`tea pr create` fails with write:repository alone even though clone, fetch
and push all work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
The relay was forwarding X-Gitea-Event and re-signing the body into the
deprecated generic-V1 X-Webhook-Signature header. Neither is something
Hermes acts on, which left the PR's core premise — "Hermes owns event
selection" — impossible to reach:
- Hermes reads the event name only from X-GitHub-Event/X-GitLab-Event,
then payload event_type/type, then falls back to the literal string
"unknown" (gateway/platforms/webhook.py). Gitea sends X-Gitea-Event and
no such payload key, so every delivery arrived as "unknown" and
`hermes webhook subscribe --events ...` could never select anything.
- Gitea's addDefaultHeaders() already signs every webhook type with
X-Hub-Signature-256 in GitHub's exact format, and Hermes accepts that
header on any route with no per-route provider gating. Re-signing into
V1 was both redundant and on a deprecated path.
So the relay now verifies the signature (accepting either X-Hub-Signature-256
or X-Gitea-Signature), forwards body and signature byte-for-byte, and copies
the one header Hermes actually needs. Authentication alone never justified
this service; that header copy does, and the module comment now says so.
Also fixed:
- gitea-hermes-webhook-provision had no API readiness wait, unlike both
sibling units in the same file. After=gitea.service does not mean gitea
is serving HTTP, so under `set -e` a Type=oneshot with no Restart= would
fail on first boot and stay failed, leaving the webhook unregistered.
- podman-hermes-agent added to the secret's restartUnits. The secret
reaches the container only via sops.templates, whose rendered path never
changes, so systemd would not restart the container when the secret was
first added — hermes-agent-webhook-route then read an empty value back
out of it and subscribed with an empty secret.
- Webhook provisioning passes the request body to curl on stdin rather
than in argv, keeping the shared secret out of /proc/<pid>/cmdline.
- Missing Content-Length now returns 411 rather than 413; dropped the
unreachable non-2xx branch (urlopen raises on non-2xx); env-var secret
fallback is stripped to match the credential-file path.
Adds gitea-hermes-webhook-relay-test.py, which drives the real relay over
real HTTP against a stub Hermes and covers the header copy as a regression
test. Both nixosConfigurations still evaluate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
Provisions a dedicated PR-tier gitea account (luna) with branch protection
restricting master push/merge/approve to darman only, then wires git and
tea directly into the hermes-agent container (mounted from the host's Nix
store, credential-store + tea login set up by a host-side prepare oneshot,
repo cloned inside Hermes's own writable sandbox root at
/opt/data/workspace/homelab). Replaces an earlier standalone MCP-server
approach, scrapped in favor of direct CLI access for simplicity.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
Consolidating on jupiter's isolated instance (hosts/jupiter/hermes-agent.nix)
instead of running a second one here. Drops the module import, its
opencode_go_api_key/telegram_bot_token secrets, and the now-stale
cross-reference in ollama's context_length comment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nix.gc (common.nix) is weekly, too slow to catch a switch pinning the old
generation's closure on a 29G eMMC — add a full nix-collect-garbage on every
boot instead. Also set boot.swraid.mdadmConf so eval stops warning that
mdmon will crash (dormant here: the RAID0 array uses native superblocks, so
mdmon never actually runs).
sabnzbd.configFile is deprecated by the module; move to services.sabnzbd.settings
with credentials (web login, api/nzb keys, eweka.nl server) sourced from sops via
secretValues instead of living in a plaintext ini. admin_dir/log_dir are pinned
absolute at their original /mnt/data location so the existing download
queue/history isn't reset by the ini moving to /var/lib/sabnzbd.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Points Nous Research's Hermes Agent at terra's own ROCm ollama server
(gemma4:12b) as a custom OpenAI-compatible provider instead of a cloud
key. Native systemd mode via the hermes-agent flake's own NixOS module
— simpler than container mode, avoids the podman-rootful-sudo dance
its docs call out.
Also bumps OLLAMA_CONTEXT_LENGTH (and Hermes' mirrored model.context_length)
from ollama's ~4k default to 131072, load-tested with real multi-ten-
thousand-token prompts rather than just idle `ollama ps` checks — chosen
as the practical ceiling where VRAM headroom and prefill throughput both
start visibly degrading, not just the largest number that technically fit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Enables hardware.graphics + intel-media-driver for the Apollo Lake's
Gen9 iGPU (VAAPI only — QSV needs an insecure/EOL runtime on this
chip) and adds jellyfin's service user to video/render for the DRI
card node. 4K HDR still can't be tone-mapped on this hardware; those
files need to direct-play or be kept as 1080p SDR.
Also relocates podman's container storage and immich's postgres
cluster to /mnt/data/AppData, after a deploy holding two ~9G closures
at once filled the 29G eMMC and postgres died mid-write.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Local LLM server on the 6800 XT (ollama-rocm, gfx1030 needs no
HSA_OVERRIDE_GFX_VERSION) fronted by a LibreChat web UI, talking to it
over the OpenAI-compatible /v1 route. Also wires up LibreChat's
persistent-memory feature, which needed its own agent+model plus a
custom extraction prompt: the default 3b model couldn't reliably tell
the user's stated facts apart from its own boilerplate, and even a
tuned prompt didn't fix that — so memory extraction now reuses
gemma4:12b, the same model as the daily-driver chat endpoint.
flake.lock bump pulls in the ollama and librechat NixOS modules.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After the rack move one of the RAID0 disks failed to enumerate, and jupiter
boot-looped into an emergency shell nobody could use — root is locked, so
sulogin offers a prompt with no answer, and there is no ssh from there:
Timed out waiting for device /dev/disk/by-uuid/dadbff6f-...
Dependency failed for /mnt/data.
Dependency failed for /var/lib/private/prowlarr.
Dependency failed for Local File Systems.
local-fs.target: Job local-fs.target/start failed with result 'dependency'
Reached target Emergency Mode.
`nofail` on /mnt/data did not help, because the prowlarr and seerr bind
mounts layered on top of it had none: without it a mount is RequiredBy
local-fs.target, so those two failed the target on the array's behalf. Give
them `nofail` too and let them fail alone. `systemd.enableEmergencyMode =
false` then keeps a bad array from costing a reachable box at all — far more
useful on a headless host than a console prompt.
Booting further is only safe if nothing quietly relocates onto the 29G eMMC,
so pin the array-backed services to the mount. systemd derives
RequiresMountsFor from a unit's own paths, which for these is somewhere under
/var/lib (eMMC) — nothing pointed immich at mediaLocation or sabnzbd at its
configFile, so with the array gone they would have started and written to the
OS disk, into directories that go invisible the moment /mnt/data mounts over
them. jellyfin, sonarr, radarr and gitea already had a real dependency and
are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Workflows push as a dedicated ci-bot account rather than a human one, so its
PAT can be scoped, rotated and revoked on its own. Adding a repo to
`ciBotRepos` and redeploying is all it takes to grant access.
Collaborator access and branch-protection push-whitelisting exist only on
gitea's HTTP API — no CLI, no config-file surface — so this one part stays
imperative: a oneshot that PUT/PATCHes the API into the desired state. It
runs on deploys where the script changed, which means it won't self-heal a
revert done through the web UI unless the unit is restarted too.
Two secrets, deliberately distinct:
- gitea_provisioning_token is darman's own token (write:repository +
write:user). Only an owner-scoped token clears reqOwnerCheck on the
collaborator and branch-protection endpoints, and write:user is what lets
it write the Actions secret below. ci-bot cannot grant itself access.
- gitea_ci_bot_token is ci-bot's push token, generated once by hand (the
command is in the comment) and pushed into gitea as a user-level Actions
secret CI_BOT_TOKEN. Gitea has no instance-wide secret scope, and every
repo here is owned by darman directly rather than an org, so a user-level
secret is the closest thing — repo-level lookups fall back to it.
Branch protection is applied to the default branch plus `develop`, since
version-bump.yml pushes there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c5a231b pinned the hash by hand because nixpkgs bumped 1.98.8->1.98.9 without
updating vendorHash (NixOS/nixpkgs#545860). The previous commit's lock moves
nixos-26.05 past the point where that fix was promoted from release-26.05, so
the override is now dead weight — and a stale vendorHash override is worse
than none, since it silently wins over a correct upstream value on the next
version bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- swap gtk portal/apps for cosmic (xdg-desktop-portal-cosmic, cosmic-files,
cosmic-settings) and drop dolphin/protonplus/bambu-studio (bambu-studio
moved to flatpak alongside the other comms/gaming flatpaks)
- mount jupiter's samba share at /mnt/jupiter (automount, credentials from
the same samba_password secret jupiter itself uses)
- add tea (gitea's remote API CLI) for talking to git.mgaction.town from
terra without SSHing into jupiter
- new dark icon themes (Amy, Azure Glassy, Slot Beauty) vendored from
gnome-look.org tarballs, packaged since pling download links expire
- rishot: fix Qt5Compat.GraphicalEffects QML import (was missing qt6.qt5compat
on QML_IMPORT_PATH, so quickshell failed at config-load)
- launcher widgets: stop LauncherConsole/LauncherDock from reserving
compositor space (ExclusionMode.Ignore, they're overlays not real docks);
bump LauncherCorner app icon size 28->34
- comms script: launch telegram/discord via flatpak, not native binaries
- nix-ld + boot.binfmt aarch64 emulation (for building/flashing mercury
from terra)
TEMPORARY: nixpkgs bumped tailscale 1.98.8->1.98.9 without updating
vendorHash (NixOS/nixpkgs#545860, fixed on release-26.05 but not yet
promoted to the nixos-26.05 channel branch this flake tracks). Remove once
`nix flake lock --update-input nixpkgs` picks up a fixed rev.
Runner registers against the same gitea instance and runs jobs in podman
containers (services/containers.nix), one image per runs-on label using the
catthehacker act-compatible images. Registration token comes from gitea
itself (gitea actions generate-runner-token) and is stored in
secrets/jupiter.yaml, rendered into a TOKEN=... env file via sops.templates
since gitea-actions-runner takes an EnvironmentFile, not a raw secret path.
- Hyprland workspace rules: start-communications.sh launches telegram +
discord into special:communications; qbz/discord/telegram switched to
flatpak (nix-flatpak, Flathub) — removes qbz and proton-pass-cli flake
inputs
- proton-pass-cli and claude-code sourced from nixpkgs-unstable; unstable
pkgs set threaded into home-manager via extraSpecialArgs
- GTK/libadwaita dark theme fixed: dconf color-scheme = prefer-dark written
declaratively instead of a per-session gsettings call
- scripts/keys: store/restore SSH host keys and sops age keys via Proton
Pass (ssh_host#<config> / age#<config> / age#admin naming); no jq dep,
uses pass-cli --field directly
- jq added to desktop-apps system packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Hyprland workspace rules: start-communications.sh launches telegram +
discord into special:communications; qbz/discord/telegram switched to
flatpak (nix-flatpak, Flathub) — removes qbz and proton-pass-cli flake
inputs
- proton-pass-cli and claude-code sourced from nixpkgs-unstable; unstable
pkgs set threaded into home-manager via extraSpecialArgs
- GTK/libadwaita dark theme fixed: dconf color-scheme = prefer-dark written
declaratively instead of a per-session gsettings call
- scripts/keys: store/restore SSH host keys and sops age keys via Proton
Pass (ssh_host#<config> / age#<config> / age#admin naming)
Tome (née AudibleLibrary) is darman's own Photino/.NET desktop app, private
repo on our own gitea. Fetched as a flake input over ssh with darman's
ambient key — same mechanism as any other git input, private or not.
buildDotnetModule package: the Preact/Vite frontend (Tome.App/ClientApp)
builds as its own buildNpmPackage derivation and gets copied into the
published app's wwwroot, since upstream's in-project MSBuild npm target has
no network access in the Nix sandbox. Photino.Native's runtime deps (gtk3,
webkitgtk_4_1, libnotify) are wrapped in — confirmed via readelf/ldd that
this Photino build already targets webkit2gtk-4.1, not the now-removed 4.0.
Also added dotnet-sdk + nodejs to terra for developing Tome locally, and
vivaldi (unfree, extends the existing allowUnfreePredicate).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Not in nixpkgs — upstream ships a shell launcher + QML tree with no build
step, driven entirely by `qs -p <dir>`. Packaged as a stdenvNoCC derivation
that wraps the launcher with RISHOT_CONFIG_DIR (sidesteps its argv0-relative
self-lookup, which wrapProgram breaks) and its runtime deps on PATH.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces CachyOS on the OS SSD (Kingston SA400, disko-managed). Dev-data
disks (sdc ext4 /mnt/hdd_01, LVM vg_ssd /mnt/ssd_01) stay out of disko and
are mounted as plain filesystems so they're never wiped. Desktop split into
services/desktop/desktop-hyprland.nix (session: compositor, greeter, audio,
portals) and desktop-apps.nix (things darman actually launches, including
claude-code — allowlisted alongside the other unfree desktop apps).
clientId was still the placeholder "immich" instead of Authentik's actual
generated id, and the sops secret it points at (immich_oauth_client_secret)
was never declared on jupiter. Wire both up, and set endSessionEndpoint so
logout lands back on immich instead of Authentik's "logged out" page.
jupiter had a leftover docker-compose Immich on the RAID (/mnt/data/Immich,
9.9G) that survived the NixOS install. Native module now, media at
/mnt/data/AppData/immich, caddy vhost on 2283 with a 50GB body limit
(caddy's default rejects video uploads).
The package comes from nixpkgs-unstable, the module from the 26.05 pin:
26.05 ships immich 2.7.5, but that database was last written by 3.0.0 and
migrations only run forward --
corrupted migrations: previously executed migration
1776217577402-DropAuditTable is missing
Safe because the two module files are byte-identical at these revisions;
services/media/immich.nix carries the diff command to re-check on a bump.
Drop the input once the stable pin ships >= 3.0.0.
immich needs group "users" only to traverse /mnt/data/AppData (drwx--x---);
its own dir stays 0700 immich:immich. mediaLocation is outside /var/lib, so
the module's tmpfiles entry only ADJUSTS it -- add a rule that creates it.
scripts/immich-import-legacy-db does the database half: boots a copy of the
legacy PGDATA under the matching image (PG14 + vchord 0.3.0 + pgvector
0.8.1), dumps it with the local pg_dump 17, restores into a scratch DB,
fixes ownership, and only swaps after confirmation. Never touches the
original. The old cluster ran VectorChord, not pgvecto.rs, so the smart
search and face embeddings survive -- no ML re-run.
Imported: 666 assets, 25 people, 647 clip + 359 face embeddings, 2 users.
Neither is imported by any host -- both are parked while their upstreams
settle (cinephage ships only a container image, mediamanager comes from a
community flake). Grouping them apart from services/media keeps that
category to what jupiter actually runs.
Pure rename, no content change; nothing imported them, so no host config
moves with them.
Three connected changes, all triggered by the same outage.
base_domain leaves mgaction.town. That zone has a wildcard A+AAAA pointing
at neptun, and DNS wildcards match multi-label names, so
jupiter.hosts.mgaction.town resolved publicly to NEPTUN and Caddy proxied
to itself -- a silent loop rather than a lookup failure. Nesting the
tailnet inside the LAN domain as orbit.sol keeps the theme and resolves
unambiguously, since tailscale matches routes by longest suffix.
override_local_dns = true with pihole as the only global nameserver, so
roaming devices get ad blocking and .sol names off-LAN. With it false,
globalResolvers land in the netmap's FallbackResolvers, which a phone
with carrier DNS never consults. No public fallback is listed on purpose:
tailscale treats the list as a set, so a second entry would let queries
slip past the filter whenever mercury is slow. The cost is that mercury
is now a single point of failure for tailnet DNS.
neptun and mercury opt out individually. mercury would otherwise resolve
through itself. neptun must not depend on a Pi behind a domestic line to
renew the certificates for the control server every other node needs --
and it is circular besides, since tailscaled has to resolve
vpn.mgaction.town to connect at all. Instead neptun runs a dnsmasq stub
forwarding just orbit.sol to MagicDNS on 100.100.100.100, which tailscaled
answers whenever it is running regardless of --accept-dns. That resolves
jupiter live, so the hardcoded /etc/hosts pin is gone.
Also sets dns.nameservers.split explicitly: nixpkgs renders its own
dns.split option one level too high, but headscale reads
dns.nameservers.split (hscontrol/types/config.go:722) and so does
headplane, whose DNS page dies on the missing key with "Cannot convert
undefined or null to object". The module's option is dead as written.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FTL could not write gravity.db, reporting "attempt to write a readonly
database". The database file was writable; the directory was not. sqlite
creates a sibling gravity.db-journal for every write transaction, so FTL
needs to CREATE files in /var/lib/pihole, and the tmpfiles rule left it
root-owned. The error names the database rather than the directory, which
sends you looking at the file and the filesystem, neither of which is at
fault.
Own the directory as 1000 instead -- the pihole user FTL drops to after
the entrypoint's root phase. Podman is rootful here with no userns
remapping, so the number is the same inside and out; on the host it
collides with darman, harmlessly.
The blocklists are now declared in this module and seeded by a oneshot,
because /var/lib/pihole is not declarative and a reflash took gravity
with it. INSERT OR IGNORE keyed on the URL is idempotent so it can run on
every boot, while the expensive rebuild only runs when gravity is empty.
Adding a list to the Nix attribute needs a manual `pihole -g` -- that is
deliberate, since the rebuild downloads every list and is slow on a Pi.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two changes to the control server, plus the fallout on the hosts.
OIDC via Authentik, so `tailscale up --login-server ...` opens a browser
instead of needing a pre-auth key. This is a second Authentik application,
separate from headplane's, with headscale's own /oidc/callback redirect.
Headless hosts keep using pre-auth keys. Note that headscale runs OIDC
discovery at startup and a failure is FATAL -- pointing `issuer` at an
application that does not exist yet means the control server will not
boot, so verify the discovery document before deploying.
override_local_dns = false, because the upstream default of true replaces
resolv.conf with 100.100.100.100 on every node. That silently broke the
LAN's `.sol` names -- pihole serves those and the global nameservers
return NXDOMAIN for them -- and took ad blocking down with them. It also
made each node's entire DNS depend on tailscaled, which is what had
forced --accept-dns=false onto neptun and mercury individually; both of
those workarounds are now removed, and with MagicDNS resolving properly
again neptun no longer needs its hardcoded /etc/hosts pin for jupiter.
Also serves jellyfin and seerr from jupiter, matching the ports they
already use on its LAN vhosts, and rotates the tailnet pre-auth keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Authentik provider and application now exist (slug "headplane", which
is what makes the issuer .../application/o/headplane/), so the client ID
is a real value rather than a placeholder, and the client secret and
headscale API key are in sops.
The tailscale pre-auth keys for neptun and jupiter are rotated because
the tailnet was recreated from scratch: the old headscale database went
with the VPS's OS disk, so every key issued against it is meaningless to
the new control server.
Note the headscale API key defaults to a 90d expiry. When it lapses
headplane stops listing nodes with no obvious cause -- `headscale apikeys
list` shows the date.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
By default headscale fetches https://controlplane.tailscale.com/derpmap/default
at startup and treats failure as fatal, so it cannot boot when that URL is
unreachable. A self-hosted control plane that will not start without
Tailscale's infrastructure rather misses the point of self-hosting -- and
it crash-looped for exactly that reason while neptun had no DNS.
Enable the embedded DERP server on region 999 and drop the upstream map.
The relay rides Caddy on :443, which is why that vhost already sets
flush_interval -1; only STUN needs a port of its own.
Verified against headscale 0.28.0 before committing: it starts clean with
urls = [], registers "DERP region: {RegionID:999 ...}" pointing at
vpn.mgaction.town with DERPPort 443, and brings up STUN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Probing the live Debian VPS turned up three mismatches between what it
serves and what this config declares:
- git.mgaction.town had no vhost at all. Gitea's web UI and HTTPS clones
are public today; only its SSH side (the :2222 socat forward) had been
ported, so a deploy would have taken the web side offline.
- Audiobookshelf is served as abs.mgaction.town, not the longer
audiobookshelf.mgaction.town this config used. The mobile app is
configured with the short name.
- The apex returns 200 from Caddy. Left unserved deliberately, so it now
gets Caddy's default 404; noted in a comment so it doesn't look like an
oversight next time.
Gitea's ROOT_URL was http:// while Caddy terminates TLS for that name.
Gitea builds absolute URLs from it, so clone buttons, redirects and
webhooks were handing out downgraded links.
Also record that defaultGateway6 is confirmed rather than assumed --
`ip -6 route show default` on the VPS gives "default via fe80::1 dev
eth0 metric 1024 onlink".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>