{ config, pkgs, ... }: # luna-sites — luna (the Hermes agent, hermes-agent.nix) hosts her own web apps # on mars, LAN-only, at http://mars.sol//, 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/.json {"port": N} hermesHome/sites, hers to write # ▼ luna-sites.path → luna-sites.service (root): validate, caddy validate, reload # /var/lib/luna-sites/live/.caddy root-owned, imported by caddy # /opt/data/sites-status.txt what was accepted, and why not # # Why a registry of {name, port} instead of letting her drop Caddyfile # snippets: a snippet can proxy to anything on this box (the dashboard on # 9119, the webhook listener on 8644, node-exporter) or file_server anything # caddy can read, and one syntax error keeps caddy from coming up on the next # boot. The generator only ever emits one fixed shape from a validated name # and a port inside portMin..portMax, so none of that is expressible. # # Why paths, not .mars.sol: mars has no fixed DHCP lease, and a wildcard # needs one. `address=/…/` takes an IP, and pihole-FTL's dnsmasq skips # wildcard --cname entries outside authoritative zones (cache_reload(): # `if (a->alias[1] != '*' …)`). Moving to subdomains later only changes the # fragment the generator writes; the registry format stays. # # Why a podman socket instead of ssh: what she needs is long-running processes # OUTSIDE her own container (anything started inside it dies with the # container, and sits next to her Telegram/gitea tokens). The socket gives # exactly that and no host shell. It is not a strong boundary on its own — # rootless podman socket access is code execution as luna-apps, which can read # whatever that user can — but luna-apps owns nothing and cannot enter # /var/lib/hermes (0750 root:hermes), so the apps cannot 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 # memories, so nothing in this file reaches her otherwise — see the dropped # repo clone in hermes-agent.nix's header for what happens when it doesn't. # # 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)"; # Nothing ever logs in as this user. Only its systemd user manager runs, # kept up without a session by linger, which is what brings the podman # socket and podman-restart back after a reboot. linger = true; autoSubUidGidRange = true; # rootless podman's user namespace hashedPassword = "!"; shell = "${pkgs.shadow}/bin/nologin"; }; # `--restart=always` containers only come back after a reboot through this # unit — rootless podman has no daemon to remember them. The podman module # already enables podman.socket for every user's manager; this one is # scoped 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), which the # container's uid cannot enter. This re-exposes it to group hermes, and the # proxy behind it runs as luna-apps, so it holds no access beyond the socket # it forwards to. 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 = [ # The directory, not the socket file: the socket is created by systemd # at boot, and a file bind mount would pin whatever inode was there when # the container started. 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 //." 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 never races caddy's own start. Nothing # orders caddy after THIS unit, which is what keeps the blocking # `systemctl reload caddy` from waiting on its own start job. after = [ "caddy.service" ]; # No start rate limit. The default (5 starts in 10s) is hit by nothing # more than a handful of quick writes — the VM test does exactly that — # and when it is, systemd also fails luna-sites.path for good # (unit-start-limit-hit): every later registration is silently ignored # until someone runs reset-failed. Bursts are absorbed by the debounce at # the top of the script 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 # Everything that touches luna's tree runs as the container's uid, never # as root: she controls every path under it, including swapping one for # a symlink into /etc 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: writes usually come in bursts (several files, or an editor's # write-then-rename), and every trigger that lands while this oneshot # is still activating merges into this same start job instead of # queuing another. One second collapses a burst into one run. sleep 1 # That merging also means an entry written mid-run would otherwise wait # for the next unrelated change. Compare the registry before and after, # and go again. Bounded, so a writer in a loop cannot pin the unit. 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 ''; }; }