Turns guest onboarding from an admin-only click in the Authentik UI into a traceable command a defined circle can run: !einladen creates a single-use invitation valid for three days, !verlaengern extends it twice at most, !freischalten makes it permanent, and expired accounts are deactivated automatically. Authorisation is deliberately twofold - the Authentik group decides, the invite room makes it visible. A group alone leaves no trace of who invited whom; a room alone would authorise anyone who gets in. Two deployment details matter: exactly one replica with Recreate, because a second instance would execute every command twice; and the script ConfigMap keeps its name hash so a change actually restarts the pod, avoiding the trap described in #50. Endpoints and field names were taken from the running Authentik OpenAPI schema, not guessed. Refs axion1337.chat/axion1337.chat-gitops#48
314 lines
12 KiB
Python
314 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# @concierge - Gaeste-Einladungen mit Ablauf, Freischaltung und begrenzter
|
|
# Verlaengerung (gitops#48, Design von sorb am 2026-08-01 festgezurrt).
|
|
#
|
|
# WARUM EIN EIGENER BOT UND NICHT DRAUPNIR
|
|
# Draupnir ist ein Moderationsbot ohne Lebenszyklus-Funktionen. Er kann einen
|
|
# Gast policy-seitig einschraenken, aber Links erzeugen, Ablaeufe verwalten und
|
|
# Konten deaktivieren kann er nicht. Ihn dafuer zu verbiegen hiesse, Upstream-
|
|
# Code zu forken, den wir sonst unveraendert mitziehen.
|
|
#
|
|
# WARUM AUTHENTIK UND NICHT SYNAPSE-REGISTRATION-TOKENS
|
|
# In diesem Stack laeuft Registrierung ausschliesslich ueber Authentik (MAS-OIDC).
|
|
# Synapse kennt gar keinen offenen Registrierungsweg - ein Registration-Token
|
|
# waere wirkungslos. Der natuerliche Einladungslink ist deshalb ein
|
|
# Authentik-Invitation-Token: einmalig verwendbar, mit eigenem Ablaufdatum.
|
|
#
|
|
# BERECHTIGUNG = GRUPPE **UND** RAUM
|
|
# Autoritativ ist die Mitgliedschaft in der Authentik-Gruppe (INVITE_GROUP).
|
|
# Zusaetzlich nimmt der Bot Kommandos nur im Einladungsraum an. Die Gruppe ist
|
|
# die Kontrolle, der Raum die Transparenz: Jede Einladung hinterlaesst einen
|
|
# nachlesbaren Eintrag, wer wen eingeladen hat. Beides zusammen, weil eine
|
|
# Gruppe allein unsichtbar ist und ein Raum allein nicht autorisiert.
|
|
#
|
|
# ⚠️ ZUORDNUNG MATRIX -> AUTHENTIK
|
|
# Der Bot nimmt an, dass der Matrix-Localpart dem Authentik-Benutzernamen
|
|
# entspricht (@gast:axion1337.chat -> "gast"). Das gilt in diesem Stack, weil
|
|
# MAS die Konten aus Authentik provisioniert. Stimmt das einmal nicht, findet
|
|
# der Bot den Nutzer nicht und sagt das - er raet nicht.
|
|
#
|
|
# FEHLERVERHALTEN, BEWUSST ASYMMETRISCH
|
|
# - Einladen/Freischalten scheitert LAUT: lieber keine Einladung als eine, von
|
|
# der niemand weiss.
|
|
# - Die Ablaufpruefung deaktiviert NUR, wenn Authentik sauber geantwortet hat.
|
|
# Ein API-Fehler darf nicht dazu fuehren, dass Konten reihenweise abgeschaltet
|
|
# werden - im Zweifel bleibt ein Gast einen Durchlauf laenger aktiv.
|
|
#
|
|
# Stdlib only, wie die uebrigen Bots dieses Verbunds.
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
log = logging.getLogger("concierge")
|
|
|
|
MATRIX = os.environ["MATRIX_HOMESERVER"].rstrip("/")
|
|
ROOM = os.environ["MATRIX_ROOM_ID"]
|
|
AUTHENTIK = os.environ["AUTHENTIK_URL"].rstrip("/")
|
|
INVITE_GROUP = os.environ.get("INVITE_GROUP", "invite-berechtigt")
|
|
MEMBER_GROUP = os.environ.get("MEMBER_GROUP", "members")
|
|
ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins")
|
|
INVITE_FLOW = os.environ.get("INVITE_FLOW_SLUG", "matrix-invitation")
|
|
GUEST_DAYS = int(os.environ.get("GUEST_DAYS", "3"))
|
|
MAX_RENEWALS = int(os.environ.get("MAX_RENEWALS", "2"))
|
|
SWEEP_SECONDS = int(os.environ.get("SWEEP_SECONDS", "900"))
|
|
|
|
# Attribute am Authentik-Nutzer. Praefix, damit sie nicht mit Feldern anderer
|
|
# Werkzeuge kollidieren, die sich denselben attributes-Topf teilen.
|
|
ATTR_EXPIRES = "threadnet_guest_expires_at"
|
|
ATTR_RENEWALS = "threadnet_guest_renewals"
|
|
ATTR_INVITED_BY = "threadnet_invited_by"
|
|
|
|
|
|
def _read(path_env, direct_env):
|
|
"""Token entweder aus einer Datei (Secret-Mount) oder direkt. Dateien sind
|
|
der Normalfall - ein Wert in der Umgebung steht in jedem Prozess-Dump."""
|
|
p = os.environ.get(path_env)
|
|
if p:
|
|
with open(p) as f:
|
|
return f.read().strip()
|
|
return os.environ[direct_env]
|
|
|
|
|
|
MATRIX_TOKEN = _read("MATRIX_TOKEN_FILE", "MATRIX_TOKEN")
|
|
AUTHENTIK_TOKEN = _read("AUTHENTIK_TOKEN_FILE", "AUTHENTIK_TOKEN")
|
|
|
|
|
|
def _call(url, token, method="GET", body=None, scheme="Bearer"):
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(url, data=data, method=method)
|
|
req.add_header("Authorization", f"{scheme} {token}")
|
|
if data:
|
|
req.add_header("Content-Type", "application/json")
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
raw = r.read()
|
|
return json.loads(raw) if raw else {}
|
|
|
|
|
|
def ak(path, method="GET", body=None):
|
|
return _call(f"{AUTHENTIK}/api/v3{path}", AUTHENTIK_TOKEN, method, body)
|
|
|
|
|
|
def mx(path, method="GET", body=None):
|
|
return _call(f"{MATRIX}/_matrix/client/v3{path}", MATRIX_TOKEN, method, body)
|
|
|
|
|
|
def say(text):
|
|
txn = str(int(time.time() * 1000))
|
|
room = urllib.parse.quote(ROOM)
|
|
mx(f"/rooms/{room}/send/m.room.message/{txn}", "PUT",
|
|
{"msgtype": "m.notice", "body": text})
|
|
|
|
|
|
# --- Authentik ---------------------------------------------------------------
|
|
|
|
def find_user(username):
|
|
r = ak(f"/core/users/?username={urllib.parse.quote(username)}")
|
|
for u in r.get("results", []):
|
|
if u["username"] == username:
|
|
return u
|
|
return None
|
|
|
|
|
|
def group_uuid(name):
|
|
r = ak(f"/core/groups/?name={urllib.parse.quote(name)}")
|
|
for g in r.get("results", []):
|
|
if g["name"] == name:
|
|
return g["pk"]
|
|
return None
|
|
|
|
|
|
def in_group(user, name):
|
|
return any(g.get("name") == name for g in user.get("groups_obj", []))
|
|
|
|
|
|
def set_attrs(user, **changes):
|
|
"""attributes ist ein einzelnes JSON-Feld: PATCH ersetzt es komplett. Wer nur
|
|
einen Schluessel schickt, loescht alle anderen - deshalb immer mischen."""
|
|
attrs = dict(user.get("attributes") or {})
|
|
for k, v in changes.items():
|
|
if v is None:
|
|
attrs.pop(k, None)
|
|
else:
|
|
attrs[k] = v
|
|
return ak(f"/core/users/{user['pk']}/", "PATCH", {"attributes": attrs})
|
|
|
|
|
|
def localpart(mxid):
|
|
return mxid.lstrip("@").split(":")[0]
|
|
|
|
|
|
# --- Kommandos ---------------------------------------------------------------
|
|
|
|
def darf_einladen(sender):
|
|
u = find_user(localpart(sender))
|
|
return u is not None and in_group(u, INVITE_GROUP), u
|
|
|
|
|
|
def ist_admin(sender):
|
|
u = find_user(localpart(sender))
|
|
return u is not None and in_group(u, ADMIN_GROUP)
|
|
|
|
|
|
def cmd_einladen(sender, args):
|
|
ok, _ = darf_einladen(sender)
|
|
if not ok:
|
|
say(f"{sender}: du bist nicht in der Gruppe '{INVITE_GROUP}'.")
|
|
return
|
|
name = (args or "gast").strip().replace(" ", "-")[:40]
|
|
expires = datetime.now(timezone.utc) + timedelta(days=GUEST_DAYS)
|
|
inv = ak("/stages/invitation/invitations/", "POST", {
|
|
"name": f"gast-{name}-{int(time.time())}",
|
|
"expires": expires.isoformat(),
|
|
"single_use": True,
|
|
"fixed_data": {ATTR_INVITED_BY: sender},
|
|
})
|
|
link = f"{AUTHENTIK}/if/flow/{INVITE_FLOW}/?itoken={inv['pk']}"
|
|
say(f"Einladung von {sender} fuer '{name}':\n{link}\n"
|
|
f"Einmalig verwendbar, verfaellt {expires:%d.%m.%Y %H:%M} UTC.")
|
|
|
|
|
|
def cmd_freischalten(sender, args):
|
|
if not ist_admin(sender):
|
|
say(f"{sender}: Freischalten darf nur die Gruppe '{ADMIN_GROUP}'.")
|
|
return
|
|
u = find_user(localpart(args.strip()))
|
|
if not u:
|
|
say(f"Kein Authentik-Konto zu '{args.strip()}' gefunden.")
|
|
return
|
|
set_attrs(u, **{ATTR_EXPIRES: None, ATTR_RENEWALS: None})
|
|
gid = group_uuid(MEMBER_GROUP)
|
|
if gid:
|
|
ak(f"/core/groups/{gid}/add_user/", "POST", {"pk": u["pk"]})
|
|
say(f"{u['username']} ist dauerhaft freigeschaltet (von {sender}).")
|
|
|
|
|
|
def cmd_verlaengern(sender, args):
|
|
ok, _ = darf_einladen(sender)
|
|
if not ok:
|
|
say(f"{sender}: du bist nicht in der Gruppe '{INVITE_GROUP}'.")
|
|
return
|
|
u = find_user(localpart(args.strip()))
|
|
if not u:
|
|
say(f"Kein Authentik-Konto zu '{args.strip()}' gefunden.")
|
|
return
|
|
used = int((u.get("attributes") or {}).get(ATTR_RENEWALS, 0))
|
|
if used >= MAX_RENEWALS:
|
|
say(f"{u['username']}: {MAX_RENEWALS} Verlaengerungen sind aufgebraucht. "
|
|
f"Jetzt muss ein Admin freischalten.")
|
|
return
|
|
neu = datetime.now(timezone.utc) + timedelta(days=1)
|
|
set_attrs(u, **{ATTR_EXPIRES: neu.isoformat(), ATTR_RENEWALS: used + 1})
|
|
if not u.get("is_active"):
|
|
ak(f"/core/users/{u['pk']}/", "PATCH", {"is_active": True})
|
|
say(f"{u['username']} um einen Tag verlaengert ({used + 1}/{MAX_RENEWALS}), "
|
|
f"laeuft {neu:%d.%m.%Y %H:%M} UTC ab.")
|
|
|
|
|
|
def cmd_status(_sender, _args):
|
|
r = ak("/core/users/?page_size=200")
|
|
zeilen = []
|
|
for u in r.get("results", []):
|
|
exp = (u.get("attributes") or {}).get(ATTR_EXPIRES)
|
|
if exp:
|
|
used = (u.get("attributes") or {}).get(ATTR_RENEWALS, 0)
|
|
zustand = "aktiv" if u.get("is_active") else "deaktiviert"
|
|
zeilen.append(f" {u['username']}: laeuft {exp[:16]} ab, "
|
|
f"{used}/{MAX_RENEWALS} verlaengert, {zustand}")
|
|
say("Gaeste:\n" + ("\n".join(zeilen) if zeilen else " keine offenen Gastkonten"))
|
|
|
|
|
|
def cmd_hilfe(_sender, _args):
|
|
say("!einladen <name> - Einladungslink erzeugen\n"
|
|
"!verlaengern @nutzer - um einen Tag verlaengern (begrenzt)\n"
|
|
"!freischalten @nutzer - dauerhaft freischalten (nur Admins)\n"
|
|
"!status - offene Gastkonten anzeigen")
|
|
|
|
|
|
BEFEHLE = {
|
|
"!einladen": cmd_einladen,
|
|
"!verlaengern": cmd_verlaengern,
|
|
"!freischalten": cmd_freischalten,
|
|
"!status": cmd_status,
|
|
"!hilfe": cmd_hilfe,
|
|
}
|
|
|
|
|
|
# --- Ablaufpruefung ----------------------------------------------------------
|
|
|
|
def sweep():
|
|
try:
|
|
r = ak("/core/users/?page_size=200")
|
|
except Exception as e:
|
|
# KEIN Deaktivieren bei API-Fehlern - siehe Kopfkommentar.
|
|
log.warning("Ablaufpruefung uebersprungen, Authentik nicht erreichbar: %s", e)
|
|
return
|
|
jetzt = datetime.now(timezone.utc)
|
|
for u in r.get("results", []):
|
|
exp = (u.get("attributes") or {}).get(ATTR_EXPIRES)
|
|
if not exp or not u.get("is_active"):
|
|
continue
|
|
try:
|
|
faellig = datetime.fromisoformat(exp)
|
|
except ValueError:
|
|
log.warning("%s: unlesbares Ablaufdatum %r", u["username"], exp)
|
|
continue
|
|
if faellig.tzinfo is None:
|
|
faellig = faellig.replace(tzinfo=timezone.utc)
|
|
if faellig <= jetzt:
|
|
ak(f"/core/users/{u['pk']}/", "PATCH", {"is_active": False})
|
|
say(f"Gastkonto {u['username']} ist abgelaufen und wurde deaktiviert. "
|
|
f"'!verlaengern @{u['username']}' oder Admin-Freischaltung.")
|
|
|
|
|
|
# --- Hauptschleife -----------------------------------------------------------
|
|
|
|
def main():
|
|
logging.basicConfig(level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s")
|
|
mx(f"/rooms/{urllib.parse.quote(ROOM)}/join", "POST", {})
|
|
# Ab jetzt, nicht die Raumhistorie: ein Neustart soll keine alten Kommandos
|
|
# erneut ausfuehren.
|
|
since = mx("/sync?timeout=0").get("next_batch")
|
|
log.info("bereit, Raum %s", ROOM)
|
|
letzter_sweep = 0.0
|
|
while True:
|
|
try:
|
|
if time.time() - letzter_sweep > SWEEP_SECONDS:
|
|
sweep()
|
|
letzter_sweep = time.time()
|
|
r = mx(f"/sync?since={urllib.parse.quote(since)}&timeout=30000")
|
|
since = r.get("next_batch", since)
|
|
raum = r.get("rooms", {}).get("join", {}).get(ROOM, {})
|
|
for ev in raum.get("timeline", {}).get("events", []):
|
|
if ev.get("type") != "m.room.message":
|
|
continue
|
|
c = ev.get("content", {})
|
|
if c.get("msgtype") != "m.text":
|
|
continue
|
|
text = (c.get("body") or "").strip()
|
|
wort = text.split(" ", 1)[0].lower()
|
|
if wort not in BEFEHLE:
|
|
continue
|
|
rest = text[len(wort):].strip()
|
|
try:
|
|
BEFEHLE[wort](ev["sender"], rest)
|
|
except Exception as e:
|
|
log.exception("Kommando %s fehlgeschlagen", wort)
|
|
say(f"'{wort}' fehlgeschlagen: {e}")
|
|
except urllib.error.HTTPError as e:
|
|
log.warning("HTTP %s bei /sync - warte", e.code)
|
|
time.sleep(10)
|
|
except Exception:
|
|
log.exception("Schleifenfehler")
|
|
time.sleep(10)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|