diff --git a/apps/production/kustomization.yaml b/apps/production/kustomization.yaml index 9ce967f..f1f4455 100644 --- a/apps/production/kustomization.yaml +++ b/apps/production/kustomization.yaml @@ -56,9 +56,11 @@ resources: # Wiki.js (Plattform-Wiki, ADR-0014, #0048) - wikijs-postgres-secret.yaml # SOPS, von sorb angelegt - wikijs-admin-secret.yaml # SOPS, randomisiert — Bootstrap durch den Konfig-Job + - wikijs-oidc-secret.yaml # SOPS, client_id/secret für die OIDC-Strategy - wikijs-postgres.yaml - wikijs.yaml - wiki-ingress.yaml + - wikijs-config.yaml # Konfig-Job (headless Setup + OIDC + Rollen) # Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted - # disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml @@ -79,3 +81,9 @@ configMapGenerator: - clamav_spam_checker.py options: disableNameSuffixHash: true + - name: wikijs-config-script + namespace: matrix + files: + - wikijs-config.py + options: + disableNameSuffixHash: true diff --git a/apps/production/networkpolicy.yaml b/apps/production/networkpolicy.yaml index 4a7401d..5917fa4 100644 --- a/apps/production/networkpolicy.yaml +++ b/apps/production/networkpolicy.yaml @@ -377,6 +377,9 @@ spec: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system + - podSelector: + matchLabels: + app.kubernetes.io/name: wikijs-config # Konfig-Job darf Wiki.js erreichen ports: - protocol: TCP port: http diff --git a/apps/production/wikijs-config.py b/apps/production/wikijs-config.py new file mode 100644 index 0000000..954f203 --- /dev/null +++ b/apps/production/wikijs-config.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""wikijs-config.py — idempotenter Bootstrap + Konfiguration von Wiki.js. + +Deploybares Äquivalent des manuellen Setup-Assistenten (ADR-0014, #0048/#0049). +Läuft als k8s-Job, nachdem Wiki.js oben ist; treibt die Admin-GraphQL-API. Nur +stdlib. Alle Verträge wurden am 2026-08-12 live gegen die laufende Instanz geprüft. + +Ablauf: + warten -> (falls Setup) /finalize mit SOPS-Admin -> warten auf Normalmodus + -> login -> OIDC-Strategy setzen -> Gruppen + Seitenregeln (Abschottung). +Idempotent: jeder Schritt prüft erst den Ist-Zustand. +""" +from __future__ import annotations + +import json +import os +import sys +import time +import urllib.request +import urllib.error + +WIKI = os.environ.get("WIKI_URL", "http://wikijs:3000") +ADMIN_EMAIL = os.environ["WIKI_ADMIN_EMAIL"] +ADMIN_PW = os.environ["WIKI_ADMIN_PASSWORD"] +SITE_URL = os.environ.get("WIKI_SITE_URL", "https://wiki.axion1337.chat") +CLIENT_ID = os.environ["OIDC_CLIENT_ID"] +CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"] +AUTH = os.environ.get("AUTHENTIK_URL", "https://auth.axion1337.chat") +APP_SLUG = os.environ.get("OIDC_APP_SLUG", "wiki-js") +# Der Strategy-Key bestimmt die Callback-URL (/login//callback) und MUSS mit +# der redirect_uri im Authentik-Provider übereinstimmen. +STRATEGY_KEY = os.environ.get("OIDC_STRATEGY_KEY", "d3e7d0e4-adff-4421-b016-7758c44fd697") + +ADMIN_PERMS = [ + "manage:system", "manage:users", "manage:groups", "manage:navigation", + "manage:pages", "write:pages", "read:pages", "manage:assets", "write:assets", + "read:assets", "manage:comments", "write:comments", "read:comments", + "read:history", "read:source", "write:styles", "write:scripts", + "manage:theme", "manage:api", +] +READER_PERMS = ["read:pages", "read:assets", "read:comments"] + + +def log(msg: str) -> None: + print(f"[wikijs-config] {msg}", flush=True) + + +def req(path: str, data=None, headers=None, method=None): + body = json.dumps(data).encode() if data is not None else None + r = urllib.request.Request(WIKI + path, data=body, method=method or ("POST" if body else "GET")) + r.add_header("Content-Type", "application/json") + for k, v in (headers or {}).items(): + r.add_header(k, v) + with urllib.request.urlopen(r, timeout=30) as resp: + raw = resp.read().decode() + return resp.status, (json.loads(raw) if raw else {}) + + +def gql(query: str, jwt: str | None = None, variables: dict | None = None): + h = {"Authorization": f"Bearer {jwt}"} if jwt else {} + _, d = req("/graphql", {"query": query, "variables": variables or {}}, h) + if d.get("errors"): + raise RuntimeError(json.dumps(d["errors"])) + return d["data"] + + +def wait_reachable(): + # Startseite ist HTML, nicht JSON -> nicht über req()/json.loads prüfen. + for _ in range(60): + try: + with urllib.request.urlopen(WIKI + "/", timeout=10) as r: + if r.status < 500: + return + except urllib.error.HTTPError as e: + if e.code < 500: + return + except Exception: + pass + time.sleep(3) + sys.exit("Wiki.js nicht erreichbar") + + +def in_setup_mode() -> bool: + # Normalmodus: die (öffentliche) login-Mutation ist beantwortbar. Setup-Modus: + # das Haupt-Schema ist nicht geladen -> die Mutation wirft. + try: + gql('mutation{authentication{login(username:"probe@invalid",password:"x",' + 'strategy:"local"){responseResult{succeeded}}}}') + return False + except Exception: + return True + + +def finalize(): + log("Setup-Modus -> finalize mit SOPS-Admin") + _, d = req("/finalize", { + "adminEmail": ADMIN_EMAIL, + "adminPassword": ADMIN_PW, + "adminPasswordConfirm": ADMIN_PW, + "siteUrl": SITE_URL, + "telemetry": False, + }) + if not d.get("ok"): + sys.exit(f"finalize fehlgeschlagen: {d}") + log("finalize ok, warte auf Normalmodus (Neustart)") + time.sleep(10) + for _ in range(40): + if not in_setup_mode(): + return + time.sleep(3) + sys.exit("Normalmodus nicht erreicht") + + +def login() -> str: + d = gql( + 'mutation($u:String!,$p:String!){authentication{login(username:$u,password:$p,' + 'strategy:"local"){responseResult{succeeded message} jwt}}}', + variables={"u": ADMIN_EMAIL, "p": ADMIN_PW}, + )["authentication"]["login"] + if not d["responseResult"]["succeeded"]: + sys.exit(f"Login fehlgeschlagen: {d['responseResult']['message']}") + return d["jwt"] + + +def kv(d: dict) -> list: + return [{"key": k, "value": json.dumps({"value": v})} for k, v in d.items()] + + +def ensure_oidc(jwt: str): + cfg = { + "clientId": CLIENT_ID, "clientSecret": CLIENT_SECRET, + "authorizationURL": f"{AUTH}/application/o/authorize/", + "tokenURL": f"{AUTH}/application/o/token/", + "userInfoURL": f"{AUTH}/application/o/userinfo/", + "issuer": f"{AUTH}/application/o/{APP_SLUG}/", + "logoutURL": f"{AUTH}/application/o/{APP_SLUG}/end-session/", + "emailClaim": "email", "displayNameClaim": "name", + "groupsClaim": "groups", "mapGroups": True, + "skipUserProfile": False, + } + strategies = [ + { # local muss in der Liste bleiben (Admin nutzt es), sonst Fehler. + "key": "local", "strategyKey": "local", "displayName": "Local", + "order": 0, "isEnabled": True, "selfRegistration": False, + "domainWhitelist": [], "autoEnrollGroups": [], "config": [], + }, + { + "key": STRATEGY_KEY, "strategyKey": "oidc", "displayName": "Authentik", + "order": 1, "isEnabled": True, "selfRegistration": True, + "domainWhitelist": [], "autoEnrollGroups": [], "config": kv(cfg), + }, + ] + r = gql( + 'mutation($s:[AuthenticationStrategyInput]!){authentication{updateStrategies(strategies:$s)' + '{responseResult{succeeded message}}}}', + jwt, {"s": strategies}, + )["authentication"]["updateStrategies"]["responseResult"] + if not r["succeeded"]: + sys.exit(f"OIDC-Strategy fehlgeschlagen: {r['message']}") + log("OIDC-Strategy gesetzt") + + +def group_id(jwt: str, name: str): + for g in gql("{groups{list{id name}}}", jwt)["groups"]["list"]: + if g["name"] == name: + return g["id"] + return None + + +def ensure_group(jwt: str, name: str, perms: list, rules: list): + gid = group_id(jwt, name) + if gid is None: + gql('mutation($n:String!){groups{create(name:$n){responseResult{succeeded message}}}}', + jwt, {"n": name}) + gid = group_id(jwt, name) + log(f"Gruppe '{name}' angelegt (id {gid})") + gql( + 'mutation($id:Int!,$n:String!,$p:[String]!,$r:[PageRuleInput]!){groups{update(' + 'id:$id,name:$n,redirectOnLogin:"/",permissions:$p,pageRules:$r){responseResult{succeeded message}}}}', + jwt, {"id": gid, "n": name, "p": perms, "r": rules}, + ) + log(f"Gruppe '{name}' -> Rechte+Seitenregeln gesetzt") + + +def rule(rid: str, deny: bool, perms: list, path: str): + return {"id": rid, "deny": deny, "match": "START", "roles": perms, + "path": path, "locales": []} + + +def main(): + log(f"Ziel: {WIKI}") + wait_reachable() + if in_setup_mode(): + finalize() + jwt = login() + log("eingeloggt") + ensure_oidc(jwt) + # authentik Admins: alles lesen+schreiben. wiki-anwender: nur /anwender lesen. + ensure_group(jwt, "authentik Admins", ADMIN_PERMS, [rule("adm", False, ["read:pages", "write:pages", "manage:pages"], "")]) + ensure_group(jwt, "wiki-anwender", READER_PERMS, [rule("anw", False, READER_PERMS, "anwender")]) + # Guests (id 2) alle Rechte entziehen — Login-Pflicht, keine öffentliche Sicht. + gql('mutation{groups{update(id:2,name:"Guests",redirectOnLogin:"/",permissions:[],pageRules:[]){responseResult{succeeded}}}}', jwt) + log("fertig — Wiki.js konfiguriert") + + +if __name__ == "__main__": + main() diff --git a/apps/production/wikijs-config.yaml b/apps/production/wikijs-config.yaml new file mode 100644 index 0000000..90f3cba --- /dev/null +++ b/apps/production/wikijs-config.yaml @@ -0,0 +1,54 @@ +# Bootstrap-/Konfig-Job für Wiki.js (ADR-0014, #0048/#0049). Ersetzt den manuellen +# Setup-Assistenten: finalize (Admin aus SOPS) -> OIDC-Strategy -> Gruppen + +# Seitenregeln. Idempotent (live verifiziert 2026-08-12). Läuft einmal beim Deploy; +# erneut anstoßen = Job löschen, Flux legt ihn neu an (Skript ist re-runnable). +apiVersion: batch/v1 +kind: Job +metadata: + name: wikijs-config + namespace: matrix +spec: + backoffLimit: 10 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: wikijs-config + spec: + restartPolicy: OnFailure + containers: + - name: config + image: python:3.12-alpine + command: ["python3", "/script/wikijs-config.py"] + env: + - name: WIKI_URL + value: http://wikijs:3000 + - name: WIKI_SITE_URL + value: https://wiki.axion1337.chat + - name: WIKI_ADMIN_EMAIL + valueFrom: + secretKeyRef: + name: wikijs-admin-secret + key: email + - name: WIKI_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: wikijs-admin-secret + key: password + - name: OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: wikijs-oidc-secret + key: client_id + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: wikijs-oidc-secret + key: client_secret + volumeMounts: + - name: script + mountPath: /script + volumes: + - name: script + configMap: + name: wikijs-config-script diff --git a/apps/production/wikijs-oidc-secret.yaml b/apps/production/wikijs-oidc-secret.yaml new file mode 100644 index 0000000..dfd3894 --- /dev/null +++ b/apps/production/wikijs-oidc-secret.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +data: + client_id: ENC[AES256_GCM,data:QhTMVvbKj+cNAgU7oXHsnyM3y5w88lo3HVf7eQ6a/RWlaVFcbwuNm6LFaeV2fyv+mUCFJ2HtLKc=,iv:9J90hdC728jAqJ4htRGeEnd0mcrQqF7VvzOGE4NKrzg=,tag:ACgs5IAgcoKFy7noFHZHoA==,type:str] + client_secret: ENC[AES256_GCM,data:uix6U6Ve60OMUWU1+WsW5ueytELs890A3JJdCad6BoQvcrjnDq9a0z1dAzLjzY9BQqBRo8Bzyd7JBwZVM2EXiJ/cMGgTvNNmMsPlpWcWg4Wu3sUl3gl5UkszcBOlWVXx6BQ0IHSKFOZw7vQYZxFLc5fiCveD4QWwGj+5AMZyNCWJtaxVFDMpno5WFW0Ht2aBWwqHCTK0irXjfeCuxEubmlMd4zzdri5PVn1vlA==,iv:jA4qw1RVDIgk0irFMz744N8LQEM+2lI/nW8jtYXKgyk=,tag:7rrzGejQ025cRnQ+MSHSYA==,type:str] +kind: Secret +metadata: + name: wikijs-oidc-secret + namespace: matrix +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYYVdFUXVOQmRqTEJ6Zlhx + MG9aUmxaUGZYWjg5YmNYWVBZYUdIbEtGL1ZFCk9tUG1ZTGE4QzBOdzRzT0dkVy90 + VSsyaFNTK21hb0VlWVhvN0JjeTBKZ2cKLS0tIFB1cGVnZktsczRsQ0NGcmhZNWE3 + K0l2SUtFanc3UE1WRlo5TUk3OXRJVEkKWE+LvhI8MGNCy54ylmRujV+I6IywurDy + DHO71yXy2xnwDfWEV7Mcg02D2lGgGfdFqpY9ulWuFqh/qDM6CMZ4ig== + -----END AGE ENCRYPTED FILE----- + recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh + encrypted_regex: ^(data|stringData)$ + lastmodified: "2026-08-12T22:01:16Z" + mac: ENC[AES256_GCM,data:m+FzQOlNKAzByFxtrC0PPzSsE7T3gDH2Y+OvBLHEl7mcxVoB376ZsRi7op2g456NlgxX/9kD0X+t33RYzqLMe0Q5b3jzQgYPV0SfsIqgpmfid6uhHxGhzpQWGfTIImx9MP2oQqqW93uicKYi64SxbVMcC2xo27gvRpeMTvEALR8=,iv:DrZs65lJ2iSbNtXr/p637Kf0Cxuxs1UfBpgE1kAEED8=,tag:cpzPBw2a9YfllWh8+HiPxg==,type:str] + version: 3.13.3