Files
homelab/hosts/mars/luna-sites.nix
T
darmanandClaude Sonnet 5 6f24ab69ad docs: condense comments across the repo
Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
2026-09-18 21:36:30 +02:00

319 lines
13 KiB
Nix

{ config, pkgs, ... }:
# luna-sites — luna (the Hermes agent, hermes-agent.nix) hosts her own web apps
# on mars, LAN-only, at http://mars.sol/<name>/, with no nix edit per app.
#
# luna, inside hermes-agent (uid 986)
# │ podman … → $CONTAINER_HOST = /run/luna-podman/podman.sock (luna-apps:hermes 0660)
# ▼ systemd-socket-proxyd, running AS luna-apps
# luna-apps's rootless podman (its linger'd user manager) — her app containers
#
# /opt/data/sites/<name>.json {"port": N} hermesHome/sites, hers to write
# ▼ luna-sites.path → luna-sites.service (root): validate, caddy validate, reload
# /var/lib/luna-sites/live/<name>.caddy root-owned, imported by caddy
# /opt/data/sites-status.txt what was accepted, and why not
#
# A registry of {name, port}, not raw Caddyfile snippets from her: a snippet
# could proxy to anything on the box or break caddy on the next boot, while
# the generator only ever emits one validated shape.
#
# Paths, not <name>.mars.sol: mars has no fixed DHCP lease, and pihole-FTL's
# dnsmasq can't wildcard-CNAME without one.
#
# A podman socket, not ssh: gives her long-running processes outside her own
# container (which dies on restart and holds her tokens) with no host shell.
# It's not a strong boundary by itself — socket access is code execution as
# luna-apps — but luna-apps can't enter /var/lib/hermes (0750 root:hermes), so
# her apps can't reach her tokens.
#
# She learns all this from a read-only README mounted at
# /opt/data/sites-README.md (luna-sites-README.md) — she self-manages her own
# memory, so nothing else in this file reaches her.
#
# VM test: nix build .#checks.x86_64-linux.luna-sites -L (luna-sites-test.nix)
let
user = "luna-apps";
# Pinned so the user manager's socket path below is known at build time.
uid = 1001;
userSocket = "/run/user/${toString uid}/podman/podman.sock";
hermes = config.virtualisation.oci-containers.containers.hermes-agent;
hermesUid = hermes.environment.HERMES_UID;
hermesGid = hermes.environment.HERMES_GID;
# hermes-agent.nix's hermesHome — the container sees it as /opt/data.
hermesHome = "/var/lib/hermes/.hermes";
sitesDir = "${hermesHome}/sites";
statusFile = "${hermesHome}/sites-status.txt";
stateDir = "/var/lib/luna-sites";
liveDir = "${stateDir}/live";
socketDir = "/run/luna-podman";
portMin = 20000;
portMax = 20999;
readme = pkgs.replaceVars ./luna-sites-README.md {
portMin = toString portMin;
portMax = toString portMax;
};
in
{
imports = [
../../services/containers.nix
../../services/network/caddy.nix
];
# ---- luna-apps: the account her apps run as ----
users.users.${user} = {
isNormalUser = true;
inherit uid;
description = "luna's hosted web apps (rootless podman)";
# No interactive login; linger keeps its systemd user manager (and thus
# the podman socket) running across reboots without a session.
linger = true;
autoSubUidGidRange = true; # rootless podman's user namespace
hashedPassword = "!";
shell = "${pkgs.shadow}/bin/nologin";
};
# Rootless podman has no daemon to bring `--restart=always` containers back
# after a reboot; the podman module enables this for every user, scoped
# here to luna-apps.
systemd.user.services.podman-restart = {
wantedBy = [ "default.target" ];
unitConfig.ConditionUser = user;
};
# ---- the socket luna's container talks to ----
# luna-apps's own socket lives under /run/user/1001 (0700), unreachable to
# the container's uid; this re-exposes it to group hermes via a proxy that
# itself runs as luna-apps, so it holds no more access than the socket.
systemd.sockets.luna-apps-podman = {
wantedBy = [ "sockets.target" ];
listenStreams = [ "${socketDir}/podman.sock" ];
socketConfig = {
SocketUser = user;
SocketGroup = "hermes";
SocketMode = "0660";
DirectoryMode = "0755";
};
};
systemd.services.luna-apps-podman = {
description = "Forward luna's podman socket to luna-apps's rootless podman";
requires = [ "user@${toString uid}.service" ];
after = [ "user@${toString uid}.service" ];
serviceConfig = {
User = user;
ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd ${userSocket}";
};
};
# ---- luna's side ----
# Merges into hermes-agent.nix's container definition.
virtualisation.oci-containers.containers.hermes-agent = {
volumes = [
# Mounts the directory, not the socket file — a file bind mount would
# pin the inode present at container start, before systemd creates the
# socket. Read-only still permits connect().
"${socketDir}:${socketDir}:ro"
"${config.virtualisation.podman.package}/bin/podman:/usr/local/bin/podman:ro"
"${readme}:/opt/data/sites-README.md:ro"
];
# Every podman command in there goes to luna-apps, never to the rootful
# podman the container itself runs under.
environment.CONTAINER_HOST = "unix://${socketDir}/podman.sock";
};
systemd.services.podman-hermes-agent = {
wants = [ "luna-apps-podman.socket" ];
after = [ "luna-apps-podman.socket" ];
};
# ---- caddy ----
# `:80` rather than http://mars.sol, so it answers whatever name the LAN
# used to get here (mars, mars.sol, the IP). Until the generator's first run
# the import glob matches nothing, which caddy only warns about.
services.caddy.virtualHosts.":80".extraConfig = ''
import ${liveDir}/*.caddy
handle {
respond "No app registered here. luna's apps live at /<name>/." 404
}
'';
# ---- registry → caddy ----
# Fires on create/delete/rename/close-after-write of entries in sitesDir.
# While sitesDir does not exist yet, systemd watches its parents instead.
systemd.paths.luna-sites = {
wantedBy = [ "multi-user.target" ];
pathConfig.PathChanged = sitesDir;
};
systemd.services.luna-sites = {
description = "Turn luna's site registry into caddy routes";
# Also runs once at boot, for edits made while nothing was watching.
wantedBy = [ "multi-user.target" ];
# After caddy, so the reload below can't race caddy's own start; nothing
# orders caddy after this unit, so that reload never waits on its own.
after = [ "caddy.service" ];
# No start rate limit: the default (5/10s) trips from just a handful of
# quick writes and permanently disables luna-sites.path (unit-start-
# limit-hit) until someone runs reset-failed. Bursts are absorbed by the
# script's own debounce instead.
startLimitIntervalSec = 0;
path = [ pkgs.jq pkgs.util-linux pkgs.diffutils config.services.caddy.package ];
# caddy validate wants somewhere to write its data/config dirs.
environment = {
HOME = "/tmp";
XDG_DATA_HOME = "/tmp";
XDG_CONFIG_HOME = "/tmp";
};
serviceConfig = {
Type = "oneshot";
StateDirectory = "luna-sites";
StateDirectoryMode = "0755"; # caddy (User=caddy) reads live/
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
# "-": hermesHome does not exist on a box Hermes has never started on;
# the script checks for that itself.
ReadWritePaths = [ "-${hermesHome}" ];
};
script = ''
set -euo pipefail
# Runs as the container's uid, never root — she controls every path
# under it, including swapping one for a symlink between a check here
# and its use.
as_luna() { setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups -- "$@"; }
if [ ! -d ${hermesHome} ]; then
echo "${hermesHome} does not exist yet; nothing to do"
exit 0
fi
# mkdir -p leaves an existing dir untouched, so this does not re-fire
# the path unit on every run.
as_luna mkdir -p ${sitesDir}
rm -rf ${stateDir}/stage.*
report=$(mktemp)
reject() { printf '%-24s rejected %s\n' "$f" "$1" >> "$report"; }
# Written as her uid next to the target, then renamed into place, so
# she never reads a half-written file.
publish_report() {
local tmp
tmp=$(as_luna mktemp ${hermesHome}/.sites-status.XXXXXX)
{
printf '# luna-sites, %s. How this works: /opt/data/sites-README.md\n' "$(date -Is)"
if [ -n "''${1:-}" ]; then printf '%s\n' "$1"; fi
if [ -s "$report" ]; then cat "$report"; else echo "(no sites registered)"; fi
} | as_luna tee "$tmp" >/dev/null
as_luna mv -f "$tmp" ${statusFile}
}
entries() {
as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f %s %T@\n' | sort
}
generate() {
local stage entry type f name verdict port
: > "$report"
stage=$(mktemp -d ${stateDir}/stage.XXXXXX)
chmod 0755 "$stage"
while IFS= read -r -d "" entry; do
type=''${entry%% *}
f=''${entry#* }
name=''${f%.json}
if ! [[ $name =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
reject "name must match [a-z0-9][a-z0-9-]{0,31}"
continue
fi
# Refused rather than followed. The read below happens as her uid
# either way, so this is about clear feedback, not safety.
if [ "$type" != f ]; then
reject "not a regular file"
continue
fi
verdict=$(as_luna head -c 4096 -- ${sitesDir}/"$f" | jq -rs \
--argjson min ${toString portMin} --argjson max ${toString portMax} '
if length != 1 or (.[0] | type) != "object" then "expected exactly one JSON object"
else .[0].port as $p
| if ($p | type) != "number" or $p != ($p | floor) then "port must be an integer"
elif $p < $min or $p > $max then "port \($p) is outside \($min)-\($max)"
else "ok \($p | floor)" end
end
' 2>/dev/null) || verdict="not valid JSON"
case $verdict in
"ok "*) port=''${verdict#ok } ;;
*) reject "$verdict"; continue ;;
esac
if ! [[ $port =~ ^[0-9]+$ ]]; then
reject "port must be an integer"
continue
fi
# The only shape that is ever generated. Stripping the prefix means
# the app sees `/`; X-Forwarded-Prefix tells it where it really is.
{
printf '# %s\n' "${sitesDir}/$f"
printf 'redir /%s /%s/ 308\n' "$name" "$name"
printf 'handle_path /%s/* {\n' "$name"
printf '\treverse_proxy 127.0.0.1:%s {\n' "$port"
printf '\t\theader_up X-Forwarded-Prefix /%s\n' "$name"
printf '\t}\n}\n'
} > "$stage/$name.caddy"
printf '%-24s ok http://mars.sol/%s/ -> 127.0.0.1:%s\n' "$f" "$name" "$port" >> "$report"
done < <(as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f\0' | sort -z)
# Nothing she controls reaches these files except a validated name and
# an integer, so a failure here is a bug in this unit, not her entry.
printf ':80 {\n\timport %s/*.caddy\n}\n' "$stage" > "$stage.Caddyfile"
if ! caddy validate --adapter caddyfile --config "$stage.Caddyfile"; then
rm -rf "$stage" "$stage.Caddyfile"
publish_report "ERROR: the generated routes failed caddy validate, so nothing changed. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)."
exit 1
fi
rm -f "$stage.Caddyfile"
if [ -d ${liveDir} ] && diff -r ${liveDir} "$stage" >/dev/null; then
rm -rf "$stage"
else
rm -rf ${stateDir}/previous
if [ -d ${liveDir} ]; then mv ${liveDir} ${stateDir}/previous; fi
mv "$stage" ${liveDir}
# caddy's reload is all-or-nothing: on failure it keeps serving the
# old routes, so put the old files back to match what is live.
if systemctl is-active --quiet caddy.service && ! systemctl reload caddy.service; then
rm -rf ${liveDir}
if [ -d ${stateDir}/previous ]; then mv ${stateDir}/previous ${liveDir}; fi
publish_report "ERROR: caddy refused the new routes, so the previous ones are still live. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)."
exit 1
fi
rm -rf ${stateDir}/previous
fi
publish_report
}
# Debounce: any trigger landing while this oneshot is still activating
# merges into the same start job, so one second collapses a burst of
# writes (several files, an editor's write-then-rename) into one run.
sleep 1
# That same merging means an entry written mid-run would otherwise wait
# for the next unrelated trigger, so compare the registry before/after
# and rerun if it changed — bounded, so a writer in a loop can't pin it.
for attempt in 1 2 3 4 5; do
before=$(entries)
generate
if [ "$before" = "$(entries)" ]; then exit 0; fi
echo "registry changed during run $attempt; regenerating"
done
echo "registry still changing after 5 runs; leaving the rest to the next trigger" >&2
'';
};
}