#!/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 -> Gruppen + Seitenregeln (Abschottung) -> OIDC-Strategy setzen -> local auf der Login-Seite ausblenden (hideLocal). Idempotent: jeder Schritt prüft erst den Ist-Zustand. Variante B ("kein manuelles Login"): local wird NICHT deaktiviert, sondern nur per Wiki.js-eigenem hideLocal aus der Login-Seite ausgeblendet. Grund: (a) local ist nicht löschbar und Wiki.js braucht eine Formular-Strategie, sonst rendert die Login-Seite leer; (b) der Job selbst loggt sich über local ein; (c) Break-Glass bleibt über /login?all erreichbar. """ 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(): # Gibt JWT zurück. local bleibt bewusst aktiviert (Break-Glass + der Job loggt sich # damit ein) — None nur, falls local doch mal deaktiviert wurde, dann kann der Job # nichts tun. 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"] return d["jwt"] if d["responseResult"]["succeeded"] else None def _set_strategies(jwt: str, oidc_config: list): strategies = [ { # local bleibt aktiviert: der Konfig-Job loggt sich damit ein und es ist der # Break-Glass-Zugang (/login?all). Auf der Login-Seite wird es per hideLocal # ausgeblendet (siehe set_hide_local) — kein manuelles Login im Alltag. "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": oidc_config, }, ] 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"updateStrategies fehlgeschlagen: {r['message']}") def ensure_oidc(jwt: str): # Quellcode-verifiziert: Wiki.js liest jeden Config-Wert via # _.get(JSON.parse(value), 'v', null) -> der Wert MUSS als {"v": …} kodiert sein. 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, } oidc_config = [{"key": k, "value": json.dumps({"v": v})} for k, v in cfg.items()] _set_strategies(jwt, oidc_config) log("OIDC-Strategy gesetzt (local bleibt aktiv als Break-Glass)") def set_hide_local(jwt: str): # Variante B ohne local zu deaktivieren: Wiki.js' eingebautes hideLocal entfernt den # local-Provider aus der Login-Seite (nur noch "Login with Authentik" sichtbar). # Break-Glass bleibt über /login?all. authHideLocal ist ein Site-Config-Feld. r = gql('mutation{site{updateConfig(authHideLocal:true){responseResult{succeeded message}}}}', jwt)["site"]["updateConfig"]["responseResult"] if not r["succeeded"]: sys.exit(f"authHideLocal setzen fehlgeschlagen: {r['message']}") log("Login-Seite: local ausgeblendet (hideLocal=true; Break-Glass via /login?all)") def ensure_theming(jwt: str): # Branding kommt aus gemounteten statischen Dateien (/_assets/img/branding/, siehe # wikijs.yaml + platform-branding ConfigMap): öffentlich ausgeliefert, kein read:assets # für Guests, keine externe URL. Dark als Default (#0050). logo = "/_assets/img/branding/logo.png" bg = "/_assets/img/branding/alpenglow.jpg" tc = gql('{theming{config{theme iconset darkMode tocPosition injectCSS injectHead injectBody}}}', jwt)["theming"]["config"] gql('mutation($t:String!,$i:String!,$d:Boolean!,$tp:String,$c:String,$h:String,$b:String){' 'theming{setConfig(theme:$t,iconset:$i,darkMode:$d,tocPosition:$tp,injectCSS:$c,injectHead:$h,' 'injectBody:$b){responseResult{succeeded message}}}}', jwt, {"t": tc["theme"], "i": tc["iconset"], "d": True, "tp": tc["tocPosition"], "c": tc["injectCSS"], "h": tc["injectHead"], "b": tc["injectBody"]}) gql('mutation($bg:String!,$logo:String!){site{updateConfig(authLoginBgUrl:$bg,logoUrl:$logo)' '{responseResult{succeeded message}}}}', jwt, {"bg": bg, "logo": logo}) log("Theming gesetzt (Dark-Default, Logo + Login-Hintergrund aus gemounteten Assets)") def ensure_git_storage(jwt: str): # Git-Storage: Wiki.js-Inhalt nach Gitea syncen (ADR-0015). Der Cluster erreicht # git.lab nicht -> Gitea ist das Ziel, ein CI-Job kanonisiert Gitea->git.lab. # Nur der PAT (GIT_STORAGE_TOKEN) ist ein Secret; Repo/User/Branch sind Klartext. # Fehlt der Token, wird der Schritt übersprungen -> der Job bleibt lauffähig, bevor # der Storage eingerichtet ist. Config-Werte brauchen die {"v":…}-Kodierung. token = os.environ.get("GIT_STORAGE_TOKEN", "").strip() repo = os.environ.get("GIT_STORAGE_REPO", "").strip() user = os.environ.get("GIT_STORAGE_USER", "").strip() if not (token and repo and user): log("Git-Storage übersprungen (GIT_STORAGE_TOKEN/REPO/USER nicht gesetzt)") return cfg = { "authType": "basic", "repoUrl": repo, "branch": os.environ.get("GIT_STORAGE_BRANCH", "main"), "basicUsername": user, "basicPassword": token, "verifySSL": True, "defaultEmail": os.environ.get("GIT_STORAGE_EMAIL", "wiki@localhost"), "defaultName": os.environ.get("GIT_STORAGE_NAME", "Wiki"), "sshPrivateKeyMode": "path", "localRepoPath": "./data/repo", "alwaysNamespace": False, "gitBinaryPath": "", } config = [{"key": k, "value": json.dumps({"v": v})} for k, v in cfg.items()] target = {"isEnabled": True, "key": "git", "mode": "sync", "syncInterval": "PT5M", "config": config} r = gql('mutation($t:[StorageTargetInput]!){storage{updateTargets(targets:$t)' '{responseResult{succeeded message}}}}', jwt, {"t": [target]})["storage"]["updateTargets"]["responseResult"] if not r["succeeded"]: sys.exit(f"Git-Storage fehlgeschlagen: {r['message']}") log(f"Git-Storage aktiviert (Gitea, sync): {repo}") 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() if jwt is None: log("lokaler Login nicht möglich (local wurde deaktiviert) — kann nicht konfigurieren, Ende.") return log("eingeloggt") # Erst Gruppen (Schreibvorgänge), DANN OIDC — ensure_oidc deaktiviert zuletzt local. # 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) ensure_oidc(jwt) set_hide_local(jwt) ensure_theming(jwt) ensure_git_storage(jwt) log("fertig — Wiki.js konfiguriert") if __name__ == "__main__": main()