Files
axion1337.chat-gitops/apps/production/concierge-bot.py
T
Thore Cimbal f6d2761025 feat: add the concierge bot for expiring guest invitations
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
2026-08-09 12:00:00 +00:00

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()