WIP
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
{ 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
|
||||
#
|
||||
# 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 <name>.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 /<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 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
|
||||
'';
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user