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
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,85 @@
|
||||
# @concierge - Gaeste-Einladungen (gitops#48). Skript: concierge-bot.py,
|
||||
# als ConfigMap ueber den configMapGenerator in kustomization.yaml.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: concierge-bot
|
||||
namespace: matrix
|
||||
spec:
|
||||
# ⚠️ Genau EINE Instanz. Der Bot haelt eine /sync-Schleife und verarbeitet
|
||||
# Kommandos; zwei Instanzen wuerden jedes Kommando doppelt ausfuehren und
|
||||
# jede Meldung doppelt posten. Deshalb replicas: 1 UND Recreate - bei
|
||||
# RollingUpdate liefen waehrend eines Deploys kurzzeitig zwei.
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: concierge-bot
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: concierge-bot
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: bot
|
||||
image: python:3.12-alpine
|
||||
command: ["python3", "/app/concierge-bot.py"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: MATRIX_HOMESERVER
|
||||
value: "https://matrix.axion1337.chat"
|
||||
# Der Einladungsraum. ⚠️ Muss invite-only sein - der Bot prueft zwar
|
||||
# zusaetzlich die Authentik-Gruppe, aber ein offener Raum macht
|
||||
# sichtbar, wer eingeladen wurde, und das ist der halbe Zweck.
|
||||
- name: MATRIX_ROOM_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: concierge-credentials
|
||||
key: matrix-room-id
|
||||
# In-Cluster, nicht ueber die oeffentliche Adresse: spart den Umweg
|
||||
# ueber Traefik und funktioniert auch, wenn extern etwas klemmt.
|
||||
- name: AUTHENTIK_URL
|
||||
value: "http://authentik-server.authentik.svc.cluster.local"
|
||||
- name: MATRIX_TOKEN_FILE
|
||||
value: /secrets/matrix-token
|
||||
- name: AUTHENTIK_TOKEN_FILE
|
||||
value: /secrets/authentik-token
|
||||
- name: GUEST_DAYS
|
||||
value: "3"
|
||||
- name: MAX_RENEWALS
|
||||
value: "2"
|
||||
volumeMounts:
|
||||
- name: script
|
||||
mountPath: /app
|
||||
readOnly: true
|
||||
- name: creds
|
||||
mountPath: /secrets
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
volumes:
|
||||
- name: script
|
||||
configMap:
|
||||
name: concierge-bot-script
|
||||
- name: creds
|
||||
secret:
|
||||
secretName: concierge-credentials
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
@@ -52,11 +52,21 @@ resources:
|
||||
- clamav.yaml
|
||||
# Client-seitiger Scan-Dienst für verschlüsselte Räume (Issue #19-Erweiterung)
|
||||
- clamav-http-scanner.yaml
|
||||
- concierge-bot.yaml
|
||||
|
||||
# Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted -
|
||||
# disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml
|
||||
# referenziert wird (kustomize kann Referenzen nicht in opaken YAML-Strings umschreiben).
|
||||
configMapGenerator:
|
||||
# ⚠️ Bewusst OHNE disableNameSuffixHash: Der Hash im ConfigMap-Namen aendert
|
||||
# sich mit dem Skript, kustomize zieht die Referenz im Deployment nach, und
|
||||
# der Pod startet dadurch von selbst neu. Ohne das haetten wir wieder den
|
||||
# Fall aus gitops#50 - geaenderte Datei im Repo, alter Stand im laufenden
|
||||
# Prozess, und niemand merkt es.
|
||||
- name: concierge-bot-script
|
||||
namespace: matrix
|
||||
files:
|
||||
- concierge-bot.py
|
||||
- name: synapse-clamav-module
|
||||
namespace: matrix
|
||||
files:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# @concierge — Gäste-Einladungen mit Ablauf
|
||||
|
||||
**Status**: gebaut, wartet auf Zugangsdaten · Issue [#48](https://git.lab/axion1337.chat/axion1337.chat-gitops/-/issues/48)
|
||||
|
||||
Ein kleiner Bot, der Einladungslinks erzeugt, Gastkonten nach drei Tagen ablaufen
|
||||
lässt und die dauerhafte Freischaltung an eine bewusste Admin-Handlung bindet.
|
||||
|
||||
## Warum es diesen Bot gibt
|
||||
|
||||
Registrierung läuft in diesem Stack **ausschließlich über Authentik**. Bis jetzt
|
||||
hieß das: Wer jemanden einladen will, klickt in der Authentik-Oberfläche einen
|
||||
Invitation-Token zusammen. Das können nur Admins, es hinterlässt keine Spur, wer
|
||||
wen eingeladen hat, und ein Gastkonto bleibt für immer bestehen.
|
||||
|
||||
Der Bot macht daraus einen Vorgang, den ein festgelegter Kreis selbst auslösen
|
||||
kann — nachvollziehbar und mit eingebautem Ablauf.
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
```
|
||||
!einladen <name> → Authentik-Invitation (einmalig, 3 Tage) + Link im Raum
|
||||
Gast registriert sich → Konto trägt threadnet_guest_expires_at
|
||||
!verlaengern @gast → +1 Tag, höchstens 2×
|
||||
!freischalten @gast → Ablauf entfernen, in members-Gruppe (nur Admins)
|
||||
(nichts davon) → Bot deaktiviert das Konto nach Ablauf
|
||||
```
|
||||
|
||||
### Berechtigung ist zweiteilig — und das ist Absicht
|
||||
|
||||
**Authentik-Gruppe UND Einladungsraum.** Die Gruppe entscheidet, der Raum macht
|
||||
sichtbar. Eine Gruppe allein ist unsichtbar: Niemand sähe, wer eingeladen hat.
|
||||
Ein Raum allein autorisiert nicht: Wer hineinkommt, dürfte alles. Zusammen ergibt
|
||||
sich beides, und jede Einladung hinterlässt einen nachlesbaren Eintrag.
|
||||
|
||||
### Zwei Dinge, die beim Umbauen leicht kaputtgehen
|
||||
|
||||
⚠️ **Genau eine Instanz.** `replicas: 1` **und** `strategy: Recreate`. Der Bot
|
||||
hält eine `/sync`-Schleife; zwei Instanzen führen jedes Kommando doppelt aus. Bei
|
||||
`RollingUpdate` liefen während eines Deploys kurzzeitig zwei.
|
||||
|
||||
⚠️ **Die ConfigMap trägt bewusst einen Namens-Hash.** Anders als beim
|
||||
ClamAV-Modul steht hier **kein** `disableNameSuffixHash: true`. Dadurch ändert
|
||||
sich der ConfigMap-Name mit dem Skript, kustomize zieht die Referenz nach, und
|
||||
der Pod startet von selbst neu. Ohne das hätten wir den Fall aus gitops#50:
|
||||
geänderte Datei im Repo, alter Stand im laufenden Prozess.
|
||||
|
||||
### Fehlerverhalten ist absichtlich unsymmetrisch
|
||||
|
||||
- **Einladen und Freischalten scheitern laut.** Lieber keine Einladung als eine,
|
||||
von der niemand weiß.
|
||||
- **Die Ablaufprüfung deaktiviert nur, wenn Authentik sauber geantwortet hat.**
|
||||
Ein API-Fehler darf nicht dazu führen, dass Konten reihenweise abgeschaltet
|
||||
werden; im Zweifel bleibt ein Gast einen Durchlauf länger aktiv.
|
||||
|
||||
## Was zur Inbetriebnahme fehlt
|
||||
|
||||
Der Bot ist ausgerollt, **startet aber nicht**, solange das Secret fehlt — der Pod
|
||||
meldet `secret "concierge-credentials" not found`. Das ist gewollt sichtbar; ein
|
||||
Bot, der still nichts tut, wäre schlechter.
|
||||
|
||||
### 1. Matrix-Konto anlegen
|
||||
|
||||
```bash
|
||||
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
|
||||
mas-cli manage register-user concierge --yes
|
||||
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
|
||||
mas-cli manage issue-compatibility-token concierge
|
||||
```
|
||||
|
||||
### 2. Authentik-Token
|
||||
|
||||
*Admin → Verzeichnis → Tokens*. Braucht Schreibrechte auf Nutzer, Gruppen und
|
||||
Invitations. Ein eigenes Dienstkonto ist sauberer als ein Admin-Token.
|
||||
|
||||
### 3. Einladungsraum
|
||||
|
||||
Invite-only anlegen, `@concierge` einladen. Die Raum-ID ist Teil des Secrets, weil
|
||||
sie zusammen mit den Token gepflegt wird und sich beim Neuanlegen ändert.
|
||||
|
||||
### 4. Gruppen in Authentik
|
||||
|
||||
`invite-berechtigt` (wer einladen darf) und `members` (wohin Freigeschaltete
|
||||
kommen). Namen sind über `INVITE_GROUP` / `MEMBER_GROUP` änderbar.
|
||||
|
||||
### 5. Secret
|
||||
|
||||
```bash
|
||||
kubectl create secret generic concierge-credentials -n matrix \
|
||||
--from-literal=matrix-token='…' \
|
||||
--from-literal=authentik-token='…' \
|
||||
--from-literal=matrix-room-id='!….:axion1337.chat' \
|
||||
--dry-run=client -o yaml > /tmp/s.yaml
|
||||
sops -e /tmp/s.yaml > apps/production/concierge-secret.yaml
|
||||
```
|
||||
|
||||
⚠️ Über SOPS ins Repo, nicht mit `kubectl apply` von Hand — sonst kennt Flux das
|
||||
Secret nicht und es fehlt nach einem Wiederaufbau des Clusters.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- **Matrix-Localpart = Authentik-Benutzername.** Gilt hier, weil MAS aus
|
||||
Authentik provisioniert. Stimmt es einmal nicht, findet der Bot den Nutzer
|
||||
nicht und sagt das — er rät nicht.
|
||||
- **Ein deaktiviertes Konto ist nicht gelöscht.** Räume und Nachrichten bleiben.
|
||||
Löschen ist bewusst nicht Sache des Bots.
|
||||
- **Draupnir-Verzahnung** (Gast-Label → eingeschränkte Räume) ist Stufe 2 und
|
||||
nicht Teil dieser Fassung.
|
||||
Reference in New Issue
Block a user