authelia: household group; guildhall invite links
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
791a3bdf34
commit
782a1c96a6
6 changed files with 502 additions and 8 deletions
|
|
@ -127,6 +127,7 @@ in
|
||||||
./services/stardew-tracker.nix
|
./services/stardew-tracker.nix
|
||||||
./services/flowtrack.nix
|
./services/flowtrack.nix
|
||||||
./services/guildhall.nix
|
./services/guildhall.nix
|
||||||
|
./services/guildhall-join.nix
|
||||||
./services/hardware-health.nix
|
./services/hardware-health.nix
|
||||||
./services/wan-watchdog.nix
|
./services/wan-watchdog.nix
|
||||||
./services/deployment.nix
|
./services/deployment.nix
|
||||||
|
|
|
||||||
94
scripts/guildhall-join-check.py
Normal file
94
scripts/guildhall-join-check.py
Normal file
|
|
@ -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 <nixpkgs> {}; 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)
|
||||||
294
scripts/guildhall-join.py
Normal file
294
scripts/guildhall-join.py
Normal file
|
|
@ -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 = """<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>Join Guildhall</title>
|
||||||
|
<style>
|
||||||
|
:root {{ color-scheme: dark; }}
|
||||||
|
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 16px; box-sizing: border-box;
|
||||||
|
background: #14161d; color: #e7e9f0; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }}
|
||||||
|
main {{ width: 100%; max-width: 400px; background: #212532; border: 1px solid #353b4d; border-radius: 14px; padding: 24px; }}
|
||||||
|
h1 {{ margin: 0 0 4px; font-size: 22px; }}
|
||||||
|
p {{ color: #98a1b4; margin: 0 0 16px; }}
|
||||||
|
label {{ display: block; font-size: 13px; color: #98a1b4; margin: 12px 0 4px; }}
|
||||||
|
input {{ width: 100%; box-sizing: border-box; background: #14161d; color: inherit; border: 1px solid #353b4d;
|
||||||
|
border-radius: 8px; padding: 9px 11px; font: inherit; }}
|
||||||
|
button, .go {{ display: inline-block; margin-top: 18px; width: 100%; box-sizing: border-box; text-align: center;
|
||||||
|
background: #2f6b3a; border: 1px solid #3f8a4c; color: #fff; border-radius: 8px; padding: 10px;
|
||||||
|
font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; }}
|
||||||
|
.error {{ color: #e2645c; background: rgba(226,100,92,.1); border: 1px solid rgba(226,100,92,.4);
|
||||||
|
border-radius: 8px; padding: 8px 12px; }}
|
||||||
|
.hint {{ font-size: 12.5px; margin: 4px 0 0; }}
|
||||||
|
</style></head>
|
||||||
|
<body><main>{body}</main></body></html>"""
|
||||||
|
|
||||||
|
FORM = """<h1>🏰 Join Guildhall</h1>
|
||||||
|
<p>You've been invited to a small co-op idle game. Pick a login for the city gates.</p>
|
||||||
|
{error}
|
||||||
|
<form method="post">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" value="{username}" autocomplete="username" autocapitalize="none" required>
|
||||||
|
<p class="hint">What you type when signing in. Lowercase, 3 to 20 characters.</p>
|
||||||
|
<label for="display">Display name <span>(optional)</span></label>
|
||||||
|
<input id="display" name="display" value="{display}" maxlength="30">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password" minlength="{min}" required>
|
||||||
|
<label for="confirm">Password again</label>
|
||||||
|
<input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="{min}" required>
|
||||||
|
<button>Create my account</button>
|
||||||
|
</form>"""
|
||||||
|
|
||||||
|
|
||||||
|
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("<h1>This invite doesn't work</h1><p>It may have been used already, or it expired. "
|
||||||
|
"Ask whoever sent it for a new one.</p>", HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
def form(self, error: str = "", username: str = "", display: str = "") -> None:
|
||||||
|
self.page(FORM.format(
|
||||||
|
error=f'<p class="error">{html.escape(error)}</p>' 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"<h1>Welcome, {html.escape(name)}!</h1><p>Your account is ready. Sign in with it at the city gates "
|
||||||
|
f"and create your adventurer.</p><a class=\"go\" href=\"{html.escape(SITE)}\">Go to Guildhall</a>")
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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:]))
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
# services/authelia.nix — Native Authelia SSO
|
# services/authelia.nix — Native Authelia SSO
|
||||||
# Secrets live in /var/secrets/authelia (root:authelia-main, 640) — see readme.
|
# 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, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
let
|
let
|
||||||
admin = [ "user:fred" ];
|
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
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
||||||
|
|
@ -25,15 +45,18 @@ in
|
||||||
format = "text";
|
format = "text";
|
||||||
};
|
};
|
||||||
|
|
||||||
authentication_backend.file.path = "/var/lib/authelia-main/users_database.yml";
|
authentication_backend.file = {
|
||||||
|
path = usersFile;
|
||||||
|
watch = true;
|
||||||
|
};
|
||||||
|
|
||||||
access_control = {
|
access_control = {
|
||||||
default_policy = "deny";
|
default_policy = "deny";
|
||||||
# Admin tools are fred-only (`admin`); any other account, i.e. kayla,
|
# Admin tools are fred-only (`admin`), the shared home sites need
|
||||||
# only reaches the shared household sites.
|
# the household group, and invited players only reach the game.
|
||||||
rules = [
|
rules = [
|
||||||
{ domain = "frigate.nordhammer.it"; policy = "one_factor"; }
|
{ domain = "frigate.nordhammer.it"; policy = "one_factor"; subject = household; }
|
||||||
{ domain = "camera.nordhammer.it"; policy = "one_factor"; }
|
{ domain = "camera.nordhammer.it"; policy = "one_factor"; subject = household; }
|
||||||
# { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled
|
# { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled
|
||||||
{ domain = "adguard.nordhammer.it"; policy = "one_factor"; subject = admin; }
|
{ domain = "adguard.nordhammer.it"; policy = "one_factor"; subject = admin; }
|
||||||
{ domain = "router.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 = "shelfarr.nordhammer.it"; policy = "one_factor"; subject = admin; }
|
||||||
{ domain = "sabnzbd.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 = "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 = "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"; }
|
{ domain = "guildhall.nordhammer.it"; policy = "one_factor"; }
|
||||||
# Health data: only the two people the app knows about.
|
# Health data: only the two people the app knows about.
|
||||||
{ domain = "flowtrack.nordhammer.it"; policy = "one_factor"; subject = [ "user:kayla" "user:fred" ]; }
|
{ 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";
|
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}" ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
services/guildhall-join.nix
Normal file
77
services/guildhall-join.nix
Normal file
|
|
@ -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/<token>, 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 ];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -211,11 +211,13 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
# Co-op idle game. Players are keyed on Remote-User, so nginx must
|
# 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) {
|
"guildhall.nordhammer.it" = lib.recursiveUpdate (protectedProxy 8098) {
|
||||||
locations."/".extraConfig = autheliaAuthConfig + ''
|
locations."/".extraConfig = autheliaAuthConfig + ''
|
||||||
proxy_set_header Remote-User $user;
|
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
|
# Pelican game panel. Can't turn off its own login (no such option
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue