From 782a1c96a60d87909dc4b6a138148c05ee0bc7d0 Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 18 Oct 2026 14:11:16 +0100 Subject: [PATCH] authelia: household group; guildhall invite links Co-Authored-By: Claude Opus 5.5 --- common.nix | 1 + scripts/guildhall-join-check.py | 94 ++++++++++ scripts/guildhall-join.py | 294 ++++++++++++++++++++++++++++++++ services/authelia.nix | 40 ++++- services/guildhall-join.nix | 77 +++++++++ services/nginx.nix | 4 +- 6 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 scripts/guildhall-join-check.py create mode 100644 scripts/guildhall-join.py create mode 100644 services/guildhall-join.nix diff --git a/common.nix b/common.nix index 9343663..d18bbf6 100644 --- a/common.nix +++ b/common.nix @@ -127,6 +127,7 @@ in ./services/stardew-tracker.nix ./services/flowtrack.nix ./services/guildhall.nix + ./services/guildhall-join.nix ./services/hardware-health.nix ./services/wan-watchdog.nix ./services/deployment.nix diff --git a/scripts/guildhall-join-check.py b/scripts/guildhall-join-check.py new file mode 100644 index 0000000..03fe4aa --- /dev/null +++ b/scripts/guildhall-join-check.py @@ -0,0 +1,94 @@ +"""Self-check for guildhall-join.py: invites, the append-only users file, and +that Authelia accepts the password hashes it writes. + +Run it against the same python the service uses, with authelia on PATH: + + py=$(nix build --no-link --print-out-paths --impure --expr \ + 'with import {}; python3.withPackages (ps: [ ps.pyyaml ps.argon2-cffi ])') + nix shell nixpkgs#authelia -c $py/bin/python3 scripts/guildhall-join-check.py +""" +import importlib.util +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import yaml + +HERE = Path(__file__).resolve().parent +TMP = Path(tempfile.mkdtemp()) +os.environ["GUILDHALL_JOIN_STATE"] = str(TMP / "state") +os.environ["GUILDHALL_JOIN_USERS"] = str(TMP / "users_database.yml") + +spec = importlib.util.spec_from_file_location("join", HERE / "guildhall-join.py") +j = importlib.util.module_from_spec(spec) +spec.loader.exec_module(j) + +ORIGINAL = """users: + fred: + password: "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA" + displayname: Fred + email: fred@example.org + groups: [household] + + kayla: + password: "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA" + displayname: Kayla + groups: [household] +""" +j.USERS_FILE.write_text(ORIGINAL) +failures = 0 + + +def check(label, ok): + global failures + print(("ok " if ok else "FAIL ") + label) + failures += not ok + + +def refused(fn, *args): + try: + fn(*args) + except j.Refused as reason: + return str(reason) + return None + + +token = j.mint("for alex") +check("fresh invite is open", j.open_invite(token) is not None) +check("state holds digests, not tokens", token not in (TMP / "state" / "invites.json").read_text()) +check("unknown token is closed", j.open_invite("x" * 24) is None) + +check("bad username refused", refused(j.sign_up, token, "Al", "", "longpassword", "longpassword")) +check("existing username refused", refused(j.sign_up, token, "Kayla", "", "longpassword", "longpassword")) +check("short password refused", refused(j.sign_up, token, "alex", "", "short", "short")) +check("mismatched passwords refused", refused(j.sign_up, token, "alex", "", "longpassword", "longpassword2")) +check("refusals leave the file alone", j.USERS_FILE.read_text() == ORIGINAL) +check("refusals leave the invite open", j.open_invite(token) is not None) + +name = j.sign_up(token, " Alex ", 'Alex "the Bold" Æ', "correct horse battery", "correct horse battery") +users = yaml.safe_load(j.USERS_FILE.read_text())["users"] +check("username normalised", name == "alex") +check("old users unchanged", {k: users[k] for k in ("fred", "kayla")} == yaml.safe_load(ORIGINAL)["users"]) +check("original text kept byte for byte", j.USERS_FILE.read_text().startswith(ORIGINAL.rstrip("\n"))) +check("new user only in guildhall group", users["alex"]["groups"] == ["guildhall"]) +check("display name survives quoting", users["alex"]["displayname"] == 'Alex "the Bold" Æ') +check("invite is spent", j.open_invite(token) is None) +check("spent invite refused", refused(j.sign_up, token, "sam", "", "longpassword", "longpassword")) + +expired = j.mint("old", now=1.0) +check("expired invite is closed", j.open_invite(expired) is None) + +if shutil.which("authelia"): + result = subprocess.run( + ["authelia", "crypto", "hash", "validate", "--password", "correct horse battery", "--", users["alex"]["password"]], + capture_output=True, text=True, + ) + check("authelia accepts the hash", result.returncode == 0 and "matches" in result.stdout.lower()) +else: + print("skip authelia hash check (authelia not on PATH)") + +shutil.rmtree(TMP) +sys.exit(1 if failures else 0) diff --git a/scripts/guildhall-join.py b/scripts/guildhall-join.py new file mode 100644 index 0000000..e707bfe --- /dev/null +++ b/scripts/guildhall-join.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""guildhall-join: one-time invite links that create Authelia accounts. + + guildhall-join invite [NOTE] mint a link, valid for a week, print it + guildhall-join list show open invites + guildhall-join revoke ID cancel an invite (ID from `list`) + guildhall-join serve run the /join/ page behind nginx + +Accounts land in the Authelia file database with exactly one group, +`guildhall`, which the access rules only let into the game. The file is only +ever appended to: before the new version replaces the old one, it is parsed +back and every existing user must come out unchanged. Invites are stored as +SHA-256 digests, so the state file alone can't be used to sign up. +""" + +from __future__ import annotations + +import fcntl +import hashlib +import html +import json +import os +import re +import secrets +import sys +import tempfile +import time +from contextlib import contextmanager +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs + +import yaml +from argon2 import PasswordHasher + +STATE_DIR = Path(os.environ.get("GUILDHALL_JOIN_STATE", "/var/lib/guildhall-join")) +USERS_FILE = Path(os.environ.get("GUILDHALL_JOIN_USERS", "/var/lib/authelia-users/users_database.yml")) +SITE = os.environ.get("GUILDHALL_JOIN_SITE", "https://guildhall.nordhammer.it") +PORT = int(os.environ.get("GUILDHALL_JOIN_PORT", "8099")) +GROUP = "guildhall" +INVITE_DAYS = 7 +USERNAME = re.compile(r"^[a-z][a-z0-9_-]{2,19}$") +TOKEN_PATH = re.compile(r"^/join/([A-Za-z0-9_-]{16,64})/?$") +MIN_PASSWORD = 10 +MAX_BODY = 4096 + +# Authelia's own argon2id defaults, so its hashes and ours look the same. +HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4, hash_len=32, salt_len=16) + + +class Refused(Exception): + """A reason to show the person filling in the form.""" + + +# --- invites --------------------------------------------------------------- + +def digest(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +@contextmanager +def locked(): + STATE_DIR.mkdir(parents=True, exist_ok=True) + with open(STATE_DIR / "lock", "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield + + +def load_invites() -> dict: + try: + return json.loads((STATE_DIR / "invites.json").read_text()) + except FileNotFoundError: + return {} + + +def save_invites(invites: dict) -> None: + write_atomic(STATE_DIR / "invites.json", json.dumps(invites, indent=2) + "\n", 0o600) + + +def mint(note: str, now: float | None = None) -> str: + now = now or time.time() + token = secrets.token_urlsafe(18) + with locked(): + invites = load_invites() + invites[digest(token)] = {"note": note, "created": now, "expires": now + INVITE_DAYS * 86400, "used_by": None} + save_invites(invites) + return token + + +def open_invite(token: str, now: float | None = None) -> dict | None: + invite = load_invites().get(digest(token)) + if not invite or invite["used_by"] or invite["expires"] < (now or time.time()): + return None + return invite + + +# --- the Authelia users file ------------------------------------------------- + +def write_atomic(path: Path, text: str, mode: int, gid: int = -1) -> None: + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.") + try: + with os.fdopen(fd, "w") as out: + out.write(text) + out.flush() + os.fsync(out.fileno()) + os.chmod(tmp, mode) + if gid != -1: + os.chown(tmp, -1, gid) + os.replace(tmp, path) + except BaseException: + os.unlink(tmp) + raise + + +def add_user(path: Path, username: str, display: str, password_hash: str) -> None: + """Append one user; refuse unless every existing user parses back unchanged.""" + text = path.read_text() + before = yaml.safe_load(text)["users"] + if username.lower() in (name.lower() for name in before): + raise Refused("That username is taken.") + block = ( + f" {username}:\n" + f" displayname: {json.dumps(display)}\n" + f" password: {json.dumps(password_hash)}\n" + f" groups:\n" + f" - {GROUP}\n" + ) + new_text = text.rstrip("\n") + "\n\n" + block + after = yaml.safe_load(new_text)["users"] + expected = {**before, username: {"displayname": display, "password": password_hash, "groups": [GROUP]}} + if after != expected: + raise RuntimeError("appending to the users file would change other entries; refusing") + info = path.stat() + write_atomic(path, new_text, info.st_mode & 0o777, info.st_gid) + + +def sign_up(token: str, username: str, display: str, password: str, confirm: str, now: float | None = None) -> str: + username = username.strip().lower() + display = " ".join(display.split()) or username.capitalize() + if not USERNAME.match(username): + raise Refused("Usernames are 3 to 20 characters: lowercase letters, digits, - and _, starting with a letter.") + if len(display) > 30 or not display.isprintable(): + raise Refused("Keep the display name under 30 characters.") + if len(password) < MIN_PASSWORD: + raise Refused(f"Use at least {MIN_PASSWORD} characters for the password.") + if len(password) > 128: + raise Refused("That password is too long.") + if password != confirm: + raise Refused("The two passwords don't match.") + password_hash = HASHER.hash(password) + with locked(): + invites = load_invites() + if not open_invite(token, now): + raise Refused("This invite has already been used or has expired.") + add_user(USERS_FILE, username, display, password_hash) + invites[digest(token)]["used_by"] = username + save_invites(invites) + return username + + +# --- the page ---------------------------------------------------------------- + +PAGE = """ + + + +Join Guildhall + +
{body}
""" + +FORM = """

🏰 Join Guildhall

+

You've been invited to a small co-op idle game. Pick a login for the city gates.

+{error} +
+ + +

What you type when signing in. Lowercase, 3 to 20 characters.

+ + + + + + + +
""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "guildhall-join" + + def log_message(self, fmt: str, *args) -> None: + # Never log paths: they carry the invite token. + pass + + def page(self, body: str, status: HTTPStatus = HTTPStatus.OK) -> None: + data = PAGE.format(body=body).encode() + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Frame-Options", "DENY") + self.end_headers() + self.wfile.write(data) + + def dead_end(self) -> None: + self.page("

This invite doesn't work

It may have been used already, or it expired. " + "Ask whoever sent it for a new one.

", HTTPStatus.NOT_FOUND) + + def form(self, error: str = "", username: str = "", display: str = "") -> None: + self.page(FORM.format( + error=f'

{html.escape(error)}

' if error else "", + username=html.escape(username), display=html.escape(display), min=MIN_PASSWORD, + ), HTTPStatus.BAD_REQUEST if error else HTTPStatus.OK) + + def do_GET(self) -> None: + match = TOKEN_PATH.match(self.path.split("?")[0]) + if not match or not open_invite(match.group(1)): + return self.dead_end() + self.form() + + def do_POST(self) -> None: + match = TOKEN_PATH.match(self.path.split("?")[0]) + if not match or not open_invite(match.group(1)): + return self.dead_end() + length = int(self.headers.get("Content-Length") or 0) + if length > MAX_BODY: + return self.form("That was too much to send.") + fields = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode(errors="replace")).items()} + username, display = fields.get("username", ""), fields.get("display", "") + try: + name = sign_up(match.group(1), username, display, fields.get("password", ""), fields.get("confirm", "")) + except Refused as reason: + return self.form(str(reason), username, display) + print(f"created account {name}", file=sys.stderr, flush=True) + self.page(f"

Welcome, {html.escape(name)}!

Your account is ready. Sign in with it at the city gates " + f"and create your adventurer.

Go to Guildhall") + + +# --- command line -------------------------------------------------------------- + +def main(argv: list[str]) -> int: + command = argv[0] if argv else "invite" + if command == "invite": + token = mint(" ".join(argv[1:])) + print(f"{SITE}/join/{token}") + print(f"Single use, valid for {INVITE_DAYS} days.", file=sys.stderr) + elif command == "list": + now = time.time() + for key, invite in sorted(load_invites().items(), key=lambda kv: kv[1]["created"]): + if invite["used_by"]: + state = f"used by {invite['used_by']}" + elif invite["expires"] < now: + state = "expired" + else: + state = f"open, {(invite['expires'] - now) / 86400:.1f} days left" + print(f"{key[:8]} {time.strftime('%Y-%m-%d', time.localtime(invite['created']))} {state:28} {invite['note']}") + elif command == "revoke" and len(argv) == 2: + with locked(): + invites = load_invites() + hits = [key for key in invites if key.startswith(argv[1])] + if len(hits) != 1: + print("No single invite matches that ID.", file=sys.stderr) + return 1 + del invites[hits[0]] + save_invites(invites) + print("Revoked.") + elif command == "serve": + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() + else: + print(__doc__, file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/services/authelia.nix b/services/authelia.nix index 72d315b..83bd6f1 100644 --- a/services/authelia.nix +++ b/services/authelia.nix @@ -1,8 +1,28 @@ # services/authelia.nix — Native Authelia SSO # Secrets live in /var/secrets/authelia (root:authelia-main, 640) — see readme. +# +# Users live in /var/lib/authelia-users so guildhall-join (services/ +# guildhall-join.nix) can add invited players; Authelia only reads it and +# reloads on change. Groups decide reach: `household` gets the shared home +# sites, `guildhall` gets the game and nothing else. { config, lib, pkgs, ... }: let admin = [ "user:fred" ]; + household = [ "group:household" ]; + usersDir = "/var/lib/authelia-users"; + usersFile = "${usersDir}/users_database.yml"; + + # One-time move out of Authelia's private state dir, then the same + # ownership on every start: guildhall-join writes, authelia-users reads. + prepareUsers = pkgs.writeShellScript "authelia-users-prepare" '' + set -eu + ${pkgs.coreutils}/bin/install -d -m 0750 -o guildhall-join -g authelia-users ${usersDir} + if [ ! -e ${usersFile} ]; then + ${pkgs.coreutils}/bin/cp /var/lib/authelia-main/users_database.yml ${usersFile} + fi + ${pkgs.coreutils}/bin/chown guildhall-join:authelia-users ${usersFile} + ${pkgs.coreutils}/bin/chmod 0640 ${usersFile} + ''; in { config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { @@ -25,15 +45,18 @@ in format = "text"; }; - authentication_backend.file.path = "/var/lib/authelia-main/users_database.yml"; + authentication_backend.file = { + path = usersFile; + watch = true; + }; access_control = { default_policy = "deny"; - # Admin tools are fred-only (`admin`); any other account, i.e. kayla, - # only reaches the shared household sites. + # Admin tools are fred-only (`admin`), the shared home sites need + # the household group, and invited players only reach the game. rules = [ - { domain = "frigate.nordhammer.it"; policy = "one_factor"; } - { domain = "camera.nordhammer.it"; policy = "one_factor"; } + { domain = "frigate.nordhammer.it"; policy = "one_factor"; subject = household; } + { domain = "camera.nordhammer.it"; policy = "one_factor"; subject = household; } # { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled { domain = "adguard.nordhammer.it"; policy = "one_factor"; subject = admin; } { domain = "router.nordhammer.it"; policy = "one_factor"; subject = admin; } @@ -46,9 +69,9 @@ in { domain = "shelfarr.nordhammer.it"; policy = "one_factor"; subject = admin; } { domain = "sabnzbd.nordhammer.it"; policy = "one_factor"; subject = admin; } { domain = "code.nordhammer.it"; policy = "one_factor"; subject = admin; } - { domain = "reddit.nordhammer.it"; policy = "one_factor"; } + { domain = "reddit.nordhammer.it"; policy = "one_factor"; subject = household; } { domain = "panel.nordhammer.it"; policy = "one_factor"; subject = admin; } - { domain = "stardew.nordhammer.it"; policy = "one_factor"; } + { domain = "stardew.nordhammer.it"; policy = "one_factor"; subject = household; } { domain = "guildhall.nordhammer.it"; policy = "one_factor"; } # Health data: only the two people the app knows about. { domain = "flowtrack.nordhammer.it"; policy = "one_factor"; subject = [ "user:kayla" "user:fred" ]; } @@ -70,5 +93,8 @@ in notifier.filesystem.filename = "/var/lib/authelia-main/notification.txt"; }; }; + + # First, before the module's validate-config pre-start. + systemd.services.authelia-main.serviceConfig.ExecStartPre = lib.mkBefore [ "+${prepareUsers}" ]; }; } diff --git a/services/guildhall-join.nix b/services/guildhall-join.nix new file mode 100644 index 0000000..e3515fc --- /dev/null +++ b/services/guildhall-join.nix @@ -0,0 +1,77 @@ +# services/guildhall-join.nix — invite links that create Guildhall accounts. +# +# guildhall-invite [note] print a one-time link, valid for a week +# guildhall-invite list open, used and expired invites +# guildhall-invite revoke ID cancel one +# +# The link opens guildhall.nordhammer.it/join/, the one path nginx +# serves without Authelia. The page appends the new user, in the `guildhall` +# group only, to Authelia's users file (see services/authelia.nix), which +# Authelia reloads on change. The service shares a dedicated authelia-users +# group with Authelia rather than authelia-main, which would also open +# Authelia's secrets. +{ config, lib, pkgs, ... }: +let + port = 8099; + stateDir = "/var/lib/guildhall-join"; + pythonEnv = pkgs.python3.withPackages (ps: [ ps.pyyaml ps.argon2-cffi ]); + script = ../scripts/guildhall-join.py; + environment = { + GUILDHALL_JOIN_STATE = stateDir; + GUILDHALL_JOIN_USERS = "/var/lib/authelia-users/users_database.yml"; + GUILDHALL_JOIN_SITE = "https://guildhall.nordhammer.it"; + GUILDHALL_JOIN_PORT = toString port; + }; + + invite = pkgs.writeShellScriptBin "guildhall-invite" '' + case "''${1:-}" in + list|revoke) ;; + *) set -- invite "$@" ;; + esac + exec /run/wrappers/bin/sudo -u guildhall-join ${pkgs.coreutils}/bin/env \ + ${lib.concatStringsSep " " (lib.mapAttrsToList (k: v: "${k}=${lib.escapeShellArg v}") environment)} \ + ${pythonEnv}/bin/python3 ${script} "$@" + ''; +in +{ + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + users.groups.authelia-users = { }; + users.groups.guildhall-join = { }; + users.users.guildhall-join = { + isSystemUser = true; + group = "guildhall-join"; + extraGroups = [ "authelia-users" ]; + description = "Guildhall invite page"; + }; + users.users.authelia-main.extraGroups = [ "authelia-users" ]; + + systemd.tmpfiles.rules = [ "d ${stateDir} 0700 guildhall-join guildhall-join -" ]; + + systemd.services.guildhall-join = { + description = "Guildhall invite page"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "authelia-main.service" ]; + inherit environment; + serviceConfig = { + ExecStart = "${pythonEnv}/bin/python3 ${script} serve"; + User = "guildhall-join"; + Group = "guildhall-join"; + Restart = "on-failure"; + RestartSec = 3; + UMask = "0077"; + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + ReadWritePaths = [ stateDir "/var/lib/authelia-users" ]; + }; + }; + + environment.systemPackages = [ invite ]; + }; +} diff --git a/services/nginx.nix b/services/nginx.nix index 7f99e7d..2c5e64f 100644 --- a/services/nginx.nix +++ b/services/nginx.nix @@ -211,11 +211,13 @@ in ''; }; # Co-op idle game. Players are keyed on Remote-User, so nginx must - # always overwrite it with the Authelia identity. + # always overwrite it with the Authelia identity. /join/ is the one + # public path: invite links that create accounts (guildhall-join.nix). "guildhall.nordhammer.it" = lib.recursiveUpdate (protectedProxy 8098) { locations."/".extraConfig = autheliaAuthConfig + '' proxy_set_header Remote-User $user; ''; + locations."/join/".proxyPass = "http://127.0.0.1:8099"; }; # Pelican game panel. Can't turn off its own login (no such option