Compare commits

...
162 Commits
Author SHA1 Message Date
Thore Cimbal d87c432f89 elementWeb auf v0.6.0
Release des Upstream-Anschlusses (ADR-0022). Gleicher Quellstand wie rc.3
(8ca03fe), das die Abnahme bestanden hat - nur unter Release-Nummer. Die Images
sind nicht bitgleich, weil der Build die Versionszeichenkette aus git describe
ins Artefakt backt.

Produktion laeuft damit erstmals auf einem Fork, der wieder an der
Upstream-Historie haengt.

Rueckhebel: Tag zurueck auf v0.5.4.
2026-08-19 12:00:00 +00:00
Thore Cimbal de347343b3 elementWeb auf v0.6.0-rc.3
Zweiter Anlauf des Upstream-Anschlusses (ADR-0022). Entfernt den Merge-Rest,
der rc.2 die Raumliste brach, und bringt einen typecheck-Job mit, den
docker_web als needs fuehrt - dieses Image ist das erste, das ohne bestandene
Typpruefung gar nicht haette entstehen koennen.

Abnahme steht aus, in dieser Reihenfolge: Raumliste laedt, ClamAV per Zip,
ClamAV per .png (der umgezogene Bild-Pfad), Call-Teilnehmerliste.

Rueckhebel: Tag zurueck auf v0.5.4.
2026-08-19 12:00:00 +00:00
Thore Cimbal b319f21133 Historie: rc.2-Vorfall am Tag-Kommentar festhalten
Der Block beschrieb rc.2 noch als laufenden Stand.
2026-08-19 12:00:00 +00:00
Thore Cimbal 1c3df5badb elementWeb zurueck auf v0.5.4 - rc.2 bricht die Raumliste
react-soft-crash bei sorb (Rageshake 2026-08-19 15:17, Safari):
"Setting 'feature_room_list_sections' does not appear to be a setting."
aus SettingsStore.getValue in RoomListItemViewModel.generateItemSync - also
bei jedem Raumlisteneintrag.

Fehler in der Merge-Aufloesung von ADR-0022: Upstream hat den Labs-Schalter
feature_room_list_sections entfernt (Sektionen laufen jetzt ueber
RoomList.showSections). Settings.tsx hat Upstreams Fassung uebernommen, in
RoomListItemViewModel.ts blieb die alte getValue-Zeile daneben stehen.

Der Build konnte das nicht fangen: getValue nimmt einen String, der Fehler
entsteht erst zur Laufzeit.

Kandidat kommt nach dem Fix als rc.3 zurueck.
2026-08-19 12:00:00 +00:00
Thore Cimbal 0f45266426 elementWeb auf v0.6.0-rc.2 (Upstream-Anschluss v1.12.26)
Kandidat, kein Release. Bringt den Merge aus ADR-0022 in Produktion, damit die
Abnahme an einem echten Client stattfinden kann.

Zu pruefen sind die zwei Patches, die der Merge verschieben musste:
ClamAV-Fehlermeldung im Bild-Pfad und die Call-Teilnehmerliste in der Raumliste.
Der Datei-Pfad (Zip) ist unberuehrt und diente heute als Ausgangswert - der
Scanner meldete die EICAR-Datei erwartungsgemaess zweimal, beim Senden und beim
Empfangen.

Rueckhebel: Tag zurueck auf v0.5.4.
2026-08-19 12:00:00 +00:00
Thore Cimbal d809b3f695 config(element): remove the interface path to a foreign homeserver
Decision sorb. Hides the edit button beside the server name, so the homeserver can no
longer be switched through the UI, and the 401/403 login error now names the server
rather than staying generic.

Honest about its reach, in the comment as well as here: it is a surface restriction.
MatrixChat still takes hs_url from the query string in two registration flows without
consulting this setting, so a crafted link is unaffected. Against
GHSA-wrcp-5v3v-3j6v - open since 2026-07-20, affecting everything below 1.12.22 while
we run 1.12.17 - it narrows the way in without closing it. The update in management
#0099 remains the actual fix.

The matching line went into the desktop client separately, since that one carries its
own config.json.
2026-08-19 12:00:00 +00:00
Thore Cimbal 1e65f5b3dd ci: stop creating pipelines that have no jobs to run
Pipeline 518 today was red with zero jobs - a push where no rule matched, which
GitLab still turns into a pipeline and then marks failed. The standstill check
reported it correctly, and it was the only finding standing between the scheduled
checks and green.

Acknowledging it would have been the wrong move twice over: the entry would name one
pipeline id, so the next empty pipeline files a fresh finding, and the underlying
behaviour - red without a fault - is exactly what management #0104 is about. Rules
that prevent the empty pipeline remove the class instead.

schedule comes first deliberately. The daily canonization changes no paths, so a
changes-based rule alone would drop it.
2026-08-19 12:00:00 +00:00
Thore Cimbal 3935f359f9 feat(synapse): close federation with an empty domain whitelist (#0060)
Decision sorb. Measured basis rather than preference: four months of operation with
zero destinations, zero remote users and zero rooms with outside participation, while
the federation API answered publicly - the delegation routes it over 443, so 8448
being shut never mattered.

An empty list federates with nobody and one entry opens it for exactly that domain,
so the capability stays one line away rather than gone.

The comment records what must not be done instead, because it is not obvious and it
would look correct: blocking /_matrix/federation at the edge. lk-jwt-service verifies
OpenID tokens through /_matrix/federation/v1/openid/userinfo and reaches it over the
public name - no hostAliases, ClusterFirst DNS - so a path-level block kills group
calls. Synapse serves that endpoint without an X-Matrix signature, so the whitelist
does not touch it.

Caught while validating: the first version of this edit split the auto_join block,
moving auto_join_rooms_for_guests under federation. Functionally identical after the
fragments merge, wrong to read, and fixed before pushing - the diff is now 20 added
lines and nothing moved.
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 5 e7469a36b8 feat(netpol): deny pods the metadata service, leave everything else open (#0088)
First egress rule in matrix, authentik and monitoring. It allows DNS, the cluster
ranges and the whole internet, and denies only 169.254.0.0/16 - link-local, where
Hetzner serves instance metadata unauthenticated to any pod.

Deliberately narrow. The textbook cut, 0.0.0.0/0 except RFC1918, would have severed
two things here, both over 10.0.0.3 on the private Hetzner network: Alloy writes
metrics and logs there, and the TURN rotation reaches Gitea through a hostAlias to
that address. Private ranges therefore stay open.

The payoff is modest and should be stated as such: measured from a pod, the service
answers with instance-id, hostname, region, MAC and network config, while userdata
and public-keys are empty. No credentials are exposed here, unlike the AWS case this
hardening usually targets. It costs nothing though, and it closes the class.

Two preconditions checked rather than assumed, because both are the usual way this
breaks: kube-system carries kubernetes.io/metadata.name so the DNS rule actually
matches, and the cluster is IPv4-only so 0.0.0.0/0 really does cover everything.

Rollback is deleting the one policy per namespace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 5 8473a1f0e5 ci: remove the CANONIZE_TOKEN probe, it did its job
Pipeline 521: the variable is visible inside the job, the push to a throwaway ref
succeeded and the ref was removed again. Combined with the token's own properties -
Maintainer, write_repository only - the monthly canonization has what it needs on
2026-09-01.

Two things the probe itself taught, kept here rather than in the removed block: an
API-triggered pipeline has source "api", not "web", so the first attempt silently
skipped the job entirely; and this repo has no "pruefen" stage, which rejected the
pipeline outright. Both would have cost the same time again next year.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 5 c3e42be0d1 ci: the probe never ran - an API trigger has source api, not web
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 5 e816d76f60 ci: drop the pruefen stage from the probe - this repo has no such stage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 5 e055ec511d ci: temporary probe that CANONIZE_TOKEN reaches the pipeline
Runs only on a web pipeline started with TEST_CANONIZE=1, pushes a throwaway ref
and deletes it again, and never touches main. Removed after the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 5 dfb88a375e feat(wiki): enrol every Authentik login into wiki-anwender
Decision sorb, and it was already made in #0049: normal Authentik users read the
user documentation, admins are admins. The role model was implemented; the way in
was not. selfRegistration created an account on first login and autoEnrollGroups
was empty, so the account landed in no group at all - and since Guests is stripped
of every permission, the user saw nothing and was told nothing about why. That is
#0103, and it happened to a real person.

Admins stay manual: membership in "authentik Admins" arrives through the groups
claim and is not affected by this baseline. betrieb/* keeps its default deny, so
the separation #0049 verified end to end still holds - it only stops applying to
people who were never let in at all.

The lookup aborts if wiki-anwender is missing rather than silently enrolling into
nothing, which would reproduce the exact failure this fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:00:00 +00:00
Thore CimbalandClaude Opus 5 ccf04608f5 ci(canonize): prune the Gitea remote, or deleted branches never disappear
Observed rather than reasoned: after the leftover rotation branch was deleted on
both remotes, pipeline 492 still reported it. The runner recycles its workspace
under GIT_STRATEGY=fetch, and git fetch without --prune keeps remote-tracking refs
for branches that no longer exist upstream. The job would have kept naming a branch
nobody can find, indefinitely - and before the previous commit it would have kept
failing on it, with deletion offering no way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:00:00 +00:00
Thore CimbalandClaude Opus 5 14cf9318ea ci(canonize): skip superseded rotation branches instead of conflicting forever
The daily canonize_rotation job had been red for nine days. Cause: the branch
turn-secret-rotation-20260728-192656 was merged on Gitea back in July but never
deleted, so the job kept trying to merge it into a main that had moved on. Its
merge base is ancient, so the merge conflicts in ten files - not only docs but
coturn-secret.yaml, synapse-turn-secret.yaml and element-server-suite.yaml.

That made the failure worse than noise. The job's own advice is "resolve by hand",
and a careless resolution there rolls the TURN shared secret back to the July
value; Synapse and coturn would then disagree and TURN would be dead. main already
carries a newer rotation (2026-08-01 against 2026-07-28), so there was never
anything to gain from the merge.

The job now reads the SOPS lastmodified stamp - metadata, not a secret - from both
sides and skips a branch whose rotation is not newer than main's, naming it as
cleanup. It stays green while doing so, deliberately: AGENTS.md makes this red
pipeline the only alarm channel for the Gitea exception, and a pipeline that is red
every day for housekeeping is not an alarm any more. A genuine conflict still fails,
now with an explicit warning never to take the older secret.

Verified both directions against the real repository state, not just the happy path:
the leftover branch is detected as superseded, and with the roles swapped a real
rotation is still recognised as needing canonization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 0d9cc03bfd fix(element-web): roll out v0.5.4 — enforce the filtered send path
On Safari LiveKit silently skipped the sender track swap, so the raw microphone
stayed on the wire regardless of the suppression level. The fork now verifies
and enforces the swap and states the outcome in the console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 55a06bc723 feat(element-web): roll out v0.5.3 — AI noise suppression released
The gate opens after the passed two-person acceptance: checkbox and slider are
back in the in-call audio settings. Rollback lever for any regression is the
gate in threadnet-call, not a deployment revert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 625d8fbb17 feat(element-web): roll out v0.5.2 — way-B filter attach, gate closed
Ships threadnet-call df4e5ee: the AI filter attaches to the microphone track
after publication with its own AudioContext on just that track. The feature
gate stays closed, so this behaves identically to v0.5.1 for every user; a
single test client opts in via two localStorage keys. The gate opens only
after the filter passes a two-person call - standing rule from #0054.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 0c9ffe5324 fix(element-web): roll out v0.5.1 — unmute incident from v0.5.0 resolved
Ships threadnet-call dcc8643: the AI filter's off-path is byte-identical to
upstream again (no processor key, noiseSuppression untouched) and the feature is
hard-gated off until the webAudioMix decision. The gate also covers clients that
still have the setting enabled in localStorage. Regression tests pin both cases
and were demonstrably red on the broken code.

Acceptance is a real two-person call after the rollout; v0.4.3 remains one
tag-revert away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 450ad51c39 revert(element-web): diagnostic window closed, back to v0.4.3
The console pinned it: with the filter on, LiveKit refuses the processor because
Element Call constructs the room without webAudioMix, so no local audio track
ever carries an AudioContext. With the filter off the same build does publish
its track, so the opt-out path itself is intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 b46040cae2 temp(element-web): v0.5.0 back up for a diagnostic window
sorb asked for it: three hypotheses about the broken unmute were disproven from
the outside, so the browser console is the only remaining source. Calls stay
broken while this runs. Goes back to v0.4.3 as soon as the console is captured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 4cd23e8990 revert(element-web): back to v0.4.3 — v0.5.0 broke unmuting
Calls connect but no participant can unmute, and the SFU log shows not a single
published track. v0.5.0 is the first production image carrying the AI noise
suppression code in the audio capture path, and v0.4.3 is the last image calls
demonstrably worked on. Restoring service first; the cause is still open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 25c5bb6286 feat(element-web): roll out v0.5.0 with AI noise suppression
Ships ADR-0018: DeepFilterNet3 as an opt-in filter in the call widget, default
off, checkbox plus slider, 35 % by default. The image now carries 23 MB of model
assets under /widgets/element-call/assets/dfn3/; they load when the user turns
the filter on, not on page load, so anyone leaving it off pays nothing.

The .7 package would have shipped a filter that was dead inside the widget and
nowhere else. Verified through the chain instead of trusting the green build:
npm package, node_modules, webpack output, and the CI artifact all carry the
assets at the path the widget requests. The last link — the running pod — gets
checked after this syncs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 83a14e1d67 feat(element): tell reporters where their report goes (#0045)
Reporting worked but ended in silence: report_event.admin_message_md was unset,
so a user who reported content saw no indication of whether it reached anyone or
whom to follow up with. For a moderated community that is an open edge.

sorb's decision is route B — reports stay in the server's event_reports store and
are reviewed through Element Admin; Draupnir deliberately does not get server
admin rights, which polling would have required. The message therefore names a
person rather than promising an automatism, and @sorb is the only admin who can
actually see the reports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 82412cf43c docs: wiki.lab is gone, Wiki.js in the stack took over
This file still told readers the doc sources are served side by side as a
Docusaurus site at wiki.lab. That site never went live and the name does not
answer at all — Wiki.js inside this stack replaced it (ADR-0014). Documentation
pointing at something dead is worse than none, since it sends the next session
looking for a service that was retired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 afc4ad3f20 feat(authentik): reject colliding usernames case-insensitively at registration
Authentik's own uniqueness is case-sensitive, so 'Boje' and 'boje' pass as
distinct while Matrix treats them as the same localpart. ADR-0011 closed the
takeover vector with on_conflict:fail, but that only bites at login: the user
registers happily and fails later with no explanation. This policy answers where
the mistake is made.

Deliberately reads only prompt_data and never request.user — the stage runs in an
anonymous enrollment context, which is exactly what the previously attached system
policies crashed on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 b4650dcef8 fix(coturn): pin the image, drop the last :latest tag
The running container was 4.10.0 while :latest had moved on to 4.17.2 — with
imagePullPolicy IfNotPresent the node keeps whatever it pulled once, so nobody
knew what was actually running and the next reschedule onto a fresh node would
have jumped seven minor versions silently. That is the concrete case #0052 is
about, and it also explains why the CVE scanner reported against a moving target.

Pinned to 4.17.2, which is both current and what :latest resolves to today, so the
scan results finally describe the thing that runs. The config uses only long-lived
core options (realm, use-auth-secret, relay-ip, cert/pkey), none of them removed
in that range. busybox in the init container goes 1.28 to 1.36, the version this
repo already uses elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 23533c7899 docs: split CLAUDE.md into a pointer plus AGENTS.md
Follows the management repo's shape (ADR-0013): CLAUDE.md is the one-line pointer
the group check looks for, AGENTS.md carries the project specifics unchanged. The
group-rules reference now points at management's AGENTS.md rather than its
CLAUDE.md, which had itself become a pointer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 60aaf0e068 feat(host-config): give the lab WireGuard tunnel a repo home
These files existed only on CFGMON, so rebuilding the host meant reconstructing
them from AAR prose (management #0027, W4 point 4). Follows the maintenance-notify
pattern: .example for anything carrying a secret, real files for the rest.

The private key stays out of the repo by design — it lives on the host and nowhere
else, and is reissued from the UDM if lost. The template carries the structural
values from ADR-0004 and the zone set from ADR-0017, with the reasoning inline:
why the direction is reversed, why AllowedIPs is narrow, why the port is 51841,
and why ping is the wrong reachability check.

Both files are derived from the ADRs rather than copied off the host, which the
README says plainly along with the redacted dump command to reconcile them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 b61dfd968e feat(backup): monthly restore drill that proves the backups are restorable
Restores the Borg archives into a throwaway postgres inside the pod and passes
only when rows actually land — the pg_restore exit code is not proof, counted
rows are. Production is never touched; the repos are only read.

Automated rather than a documented cadence: a check nobody performs is the same
mistake as an untested backup, one level up. Runs on the 4th at 04:20, after the
nightly jobs. Verified manually before commit (synapse 31908 rows, MAS 16085,
wiki 251).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 5f54fbef8e feat(wikijs): default new users to Europe/Berlin (fork patch)
Wiki.js auto-provisions SSO users in processProfile without a timezone, so
the DB column default (America/New_York) applies. Since there is no Wiki.js
image build pipeline, patch server/models/users.js at container startup to set
timezone: 'Europe/Berlin' on new-user creation. Idempotent (grep guard) and
fail-open (node starts even if the anchor is gone after an upgrade).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 2250969e4a feat(wikijs): German default locale, migrate content de, Berlin timezone
The content is German but was bound to the 'en' locale, the site default
language was English, and system accounts defaulted to America/New_York.

- ensure_locale: set default locale to 'de' (live via updateLocale) and
  migrate all pages en->de via pages.migrateToLocale, then rebuild page tree
  and search index only when something was migrated.
- Navigation tree now stored under locale 'de' so getTree finds it for the
  now-German pages.
- ensure_timezones: set guest + admin system accounts to Europe/Berlin
  (new auto-provisioned users still inherit the core-hardcoded NY default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 9f8eed3193 fix(wikijs): make sidebar nav targets absolute
The default theme renders sidebar links with href=target verbatim, without
targetType or leading-slash handling. Page targets stored without a leading
slash resolved relatively (e.g. from /betrieb/x, 'Sicherheit' went to
/betrieb/betrieb/sicherheit -> 404), and the home item's empty target was a
dead link. Prefix page targets with '/' and point home at '/'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00
Thore Cimbal 56653ffd0c feat(wiki): add Sicherheit page to the betrieb navigation (M5) 2026-08-14 12:00:00 +00:00
Thore Cimbal 026c255a45 feat(wiki): bake custom navigation + HTML rendering + UX CSS into the config job
Make the wiki UX reproducible: set_navigation writes the custom static sidebar
(Home + Anwender + Betrieb, betrieb items visible only to admin groups 1/3);
ensure_html_rendering turns on markdownCore.allowHTML (v-encoded, other renderer
configs preserved) so the HTML card landing renders — note it needs a Wiki.js
restart to take effect on a fresh instance. The injectCSS is rebuilt: content links
get an underline + accent (the old broad primary--text coloring made non-links look
like links) plus the landing card/button styles. Page content lives in git-storage.
2026-08-13 12:00:00 +00:00
Thore Cimbal df72744d55 refs(wiki): shrink login background, undo the branding ConfigMap split
Downscale alpenglow.jpg 2560->1920px (604KB -> 400KB, visually identical on the
login screen). With the smaller background everything fits in one platform-branding
ConfigMap again (~752KB / 1MiB), so drop platform-branding-bg and its /_assets/img/bg
mount; authLoginBgUrl goes back to /_assets/img/branding/alpenglow.jpg. Favicons kept
as ThreadNet.
2026-08-13 12:00:00 +00:00
Thore Cimbal 2154f7fe77 fix(wiki): replace remaining Wiki.js favicons; split branding ConfigMap
The browser tab still showed Wiki.js because only favicon.ico + 16/32 were
replaced — the larger icons the tab picks (android-chrome-192, apple-touch-180,
mstile-150) were still the defaults. Add ThreadNet versions and subPath-mount
them. Adding these pushed the single branding ConfigMap to ~1.04 MB (the 1 MiB
etcd limit), so split the 604 KB background into its own platform-branding-bg
ConfigMap mounted at /_assets/img/bg; authLoginBgUrl follows.
2026-08-13 12:00:00 +00:00
Thore Cimbal cc189d3e15 feat(wiki): nightly postgres backup + TOC on the right (#0048/#0050)
Add wikijs-backup, a nightly Borg CronJob (03:30) that dumps the Wiki.js postgres
DB to the Storage Box — the content lives in git-storage, but comments, local
accounts and the search index do not, so the runtime state gets its own backup.
Mirrors authentik-backup (DB-only), reusing synapse-backup-credentials and
-known-hosts with a separate repo path; NetworkPolicy lets the backup pod reach
wikijs-postgres. Also flip tocPosition to right so the page TOC sits beside the
content instead of in the left rail.
2026-08-13 12:00:00 +00:00
Thore Cimbal acf38107f0 feat(wiki): reader access to home page for wiki-anwender (#0048/#0049)
The Betrieb/Anwender content structure is created (lives in git-storage, not the
job). Add a page rule so wiki-anwender can read the home page besides /anwender —
otherwise "/" would be denied for them. betrieb/* still matches no rule, so
Wiki.js' default-deny keeps it invisible to users (verified in checkAccess:
returns match && !deny). rule() gains a match param for the EXACT home rule.
2026-08-13 12:00:00 +00:00
Thore Cimbal dbc7dacf2a ci(wiki): canonize wiki content Gitea->git.lab (ADR-0015)
Add canonize_wiki to the daily schedule: bare-clone the public Gitea wiki repo
(sorb/ThreadNetWiki) and push its main to git.lab axion1337.chat/threadnet-wiki,
closing the second half of ADR-0015 (Wiki.js->Gitea->canonize->git.lab, the same
reversal as the TURN rotation). The target was created empty, so the first push
creates main and later runs fast-forward — no force, branch protection intact.
Auth via the WIKI_CANONIZE_TOKEN CI variable (project token, write_repository).
2026-08-13 12:00:00 +00:00
Thore Cimbal ece80a8153 feat(wiki): accent color + favicon (#0050)
Set the blue Docusaurus-style accent (#2b6cb0 light / #63b3ed dark) via injectCSS
on the app UI — the login page is excluded because Wiki.js renders it without
custom CSS, so a dark login card is not themeable and is left as-is. Mount the
ThreadNet favicon (favicon.ico + favicon-16/32 PNGs the browser tab uses) from the
platform-branding ConfigMap via subPath, overlaying only those files.
2026-08-13 12:00:00 +00:00
Thore Cimbal 32ca547be3 feat(wiki): configurable site title via WIKI_SITE_TITLE
The site title (top-left + browser tab) was the hardcoded "Wiki.js". Drive it
from a deployment variable instead; the config job sets site.title from
WIKI_SITE_TITLE (default "Wiki"), set here to wiki.axion1337.chat.
2026-08-13 12:00:00 +00:00
Thore Cimbal 5d7301c4d2 feat(wiki): git-storage sync to Gitea (ADR-0015)
Wiki.js cannot reach git.lab from the cluster, so its content syncs to Gitea
(sorb/ThreadNetWiki) over HTTPS with a dedicated deploy PAT; a CI job canonizes
Gitea->git.lab (TURN-rotation pattern). The config job's ensure_git_storage
configures the git storage target (mode sync, config values {v:...}-encoded like
the auth strategy). Repo/user/branch/author are plain env; only the PAT lives in
the SOPS secret wikijs-git-secret, wired optional so the job still runs without it.
2026-08-13 12:00:00 +00:00
Thore Cimbal 63460e798c fix(flux): stop stripping branding images from the build artifact
Flux's default source ignore excludes *.png/*.jpg, so the platform-branding
configMapGenerator failed with "no such file or directory" for the branding
assets. Re-include just those two files via .sourceignore.
2026-08-13 12:00:00 +00:00
Thore Cimbal 7164e4f61c feat(wiki): brand login page via mounted static assets (#0050)
Serve the ThreadNet logo and the shared platform login background (alpenglow.jpg,
same file Authentik and Element use) as public static files under
/_assets/img/branding, mounted from a single platform-branding ConfigMap. This
avoids two bad patterns: linking the background via an external URL (runtime
dependency on axion1337.chat) and uploading the logo as a gated Wiki.js asset
(which 404/403s on the unauthenticated login page unless guests get read:assets).

Wiki.js serves /wiki/assets publicly at /_assets, so mounted files need no
read:assets — guests stay locked out of pages. The config job sets logoUrl and
authLoginBgUrl to the local paths and enables dark mode as default. The ConfigMap
uses a name hash so a branding change rolls the pod. It can later be mounted into
Authentik/Element too, keeping one source of truth for the shared assets.
2026-08-13 12:00:00 +00:00
Thore Cimbal c4e67244fe fix(wiki-config): hide local login via hideLocal, keep strategy enabled
Disabling local (previous approach) left the login page empty: Wiki.js needs a
form strategy to render the provider list, so with only OIDC the card was blank
and login hung. Keep local ENABLED and instead set the built-in authHideLocal
site config, which removes local from the provider list on the login page —
only "Login with Authentik" shows.

local stays usable because the config job logs in through it, and it is the
break-glass path, reachable via /login?all (Wiki.js honors ?all to bypass
hideLocal). Verified live end-to-end: job runs idempotently, both strategies
enabled, authHideLocal=true.
2026-08-13 12:00:00 +00:00
Thore Cimbal f034eabdd6 feat(wiki-config): disable local login after bootstrap (Variante B)
Nobody should log in manually. The job disables the local strategy as its final
step (with a still-valid JWT), so the login page offers only Authentik OIDC.
Re-runs without a DB reset find local disabled -> login returns None -> the job
exits cleanly (already configured). Break-glass = DB reset (finalize re-enables
local). Verified live: local login is BLOCKED after the run.
2026-08-12 12:00:00 +00:00
Thore Cimbal 81d66c25a0 fix(wiki-config): encode strategy config values as {v: ...}
The OIDC strategy failed at startup ('requires an issuer option') because the
config values never persisted: Wiki.js reads each via _.get(JSON.parse(value),
'v', null) (source-verified in server/graph/resolvers/authentication.js), so the
value MUST be {"v": <value>}. None of the earlier encodings had the 'v' key.
Fixed and verified live: strategy now loads [ OK ].
2026-08-12 12:00:00 +00:00
Thore Cimbal 026b499d1d feat(wiki): deployable Wiki.js config job (#0049) — headless setup + OIDC + roles
Idempotent GraphQL bootstrap job (verified live): /finalize with the random SOPS
admin, then the OIDC strategy and the groups 'authentik Admins' (full) and
'wiki-anwender' (read /anwender), Guests locked. Replaces the manual setup wizard
entirely. OIDC client_id/secret in a SOPS secret; NetworkPolicy lets the job reach
wikijs. Script as a fixed-name ConfigMap; re-run = delete the Job.
2026-08-12 12:00:00 +00:00
Thore Cimbal 3844f42f1f feat(wiki): randomized admin secret + wiki-anwender group (deployable #0049)
Random Wiki.js admin credentials (SOPS, never displayed) for the config Job to
bootstrap setup non-interactively. Authentik group wiki-anwender (curated readers
of /anwender); admin role = existing authentik Admins. Part of making Wiki.js
fully deployable - no manual setup wizard.
2026-08-12 12:00:00 +00:00
Thore Cimbal 3c6d9549fb feat(authentik): declare wiki-admin/wiki-betrieb/wiki-anwender groups (#0049)
Deployable group structure for the wiki roles/abschottung. Names match the
Wiki.js groups and flow to Wiki.js via the profile->groups claim. Members are
assigned in the Authentik UI (sorb), not here.
2026-08-12 12:00:00 +00:00
Thore Cimbal 64d60b4428 feat(authentik): set the wiki OIDC redirect_uri to the real Wiki.js callback
Replace the PROVIDERKEY placeholder with the actual Wiki.js OIDC strategy callback
(login/<strategy-key>/callback) so Authentik accepts the redirect back. Closes the
login loop for wiki.axion1337.chat.
2026-08-12 12:00:00 +00:00
Thore Cimbal 22da5a7910 fix(authentik): let Authentik generate the wiki OIDC client_id/secret
The wiki-oidc-provider blueprint referenced !Env AUTHENTIK_WIKI_OIDC_CLIENT_ID/
SECRET which were never set, so the blueprint failed (status: error) and no
provider was created. client_id isn't a secret and the client_secret is read
into Wiki.js manually anyway, so drop both !Env refs and let Authentik generate
them. Read the values from the Authentik UI into Wiki.js. Guide 10 design fix.
2026-08-12 12:00:00 +00:00
Thore Cimbal e110918d5f feat(wiki): wire up the wikijs-postgres-secret (created by sorb)
The SOPS-encrypted secret existed locally but was untracked and not in the
kustomization, so Flux never applied it and both wiki pods stayed in
CreateContainerConfigError (secret not found). Add it to resources and commit.
Values not read; age recipient matches .sops.yaml so Flux can decrypt.
2026-08-12 21:38:38 +02:00
Thore Cimbal ea01c0bc6d feat: add wiki oicd blueprint 2026-08-12 19:26:21 +00:00
Thore Cimbal 1744de074f feat(wiki): deploy Wiki.js in the suite (#0048) — waits on the DB secret
Wiki.js + a dedicated Postgres + public ingress for wiki.axion1337.chat, added to
the production kustomization so Flux applies it. Both pods intentionally wait on
the SOPS secret `wikijs-postgres-secret` (username/password) until sorb creates it
— same loud-but-visible pattern as the concierge bot. NetworkPolicy: Traefik ->
wikijs:http, wikijs -> wikijs-postgres:postgres (ingress-only, named container
ports). Ingress/Cert mirror the authentik pattern (letsencrypt-prod). Native OIDC
login (#0049, guide 10) and git-storage are configured post-start. All manifests
validated server-side (kubectl --dry-run=server).
2026-08-12 12:00:00 +00:00
Thore Cimbal 3200865bbb docs(wiki): guide 10 - Wiki.js access via wiki.axion1337.chat + native OIDC
How an Anwender/Admin reaches the suite wiki: public wiki.axion1337.chat, same URL
for both, Wiki.js's own OIDC login to Authentik (no forward-auth/outpost - that was
only for static Docusaurus). Ready-to-apply Ingress+Certificate manifest (authentik
pattern), the Authentik OAuth2 provider blueprint (parallel-safe slug wiki-js), and
the Wiki.js OIDC endpoints. Prepared, not deployed; supersedes guide 09 for the
suite (ADR-0014). Indexed in the guides README.
2026-08-12 12:00:00 +00:00
Thore Cimbal 27a5395e17 docs(wiki-auth): add a Fehlerbild section (denial = not in wiki-zugang)
The 'Regelwerk Bindung resultiert in False' denial after login means the user
is not a member of wiki-zugang (step 2.4). Record it plus the redirect-loop and
outpost-offline cases so the next person recognises them fast.
2026-08-12 18:02:42 +02:00
Thore Cimbal 065b13086b feat: add authentik blueprint for forwarded wiki authentication 2026-08-12 15:39:13 +00:00
Thore Cimbal cac2b88fb3 docs(wiki-auth): clarify Outpost vs Outpost Integration in step 2.2
A user hit the 'Neue Outpost-Integration' dialog, which only offers Docker/K8s
and can't be left empty. That is the service-connection creator, not the outpost.
Spell out: go to Applications -> Outposts -> Create (not Outpost Integrations),
and leave the Integration dropdown on 'No integration'.
2026-08-12 12:00:00 +00:00
Thore Cimbal 310bb65b98 docs(wiki-auth): decide hostname axionwiki.lab (#0024), detail steps 2-3
Records the #0024 decision (axionwiki.lab) and flags it as a development-time
arrangement: the wiki still has to move into the ThreadNet Server Suite, and
surface alternatives beyond BookStack get re-examined afterwards. Expands the
Authentik outpost/token steps (version pinning, exact UI path, where the token
goes) and the Overmind/Traefik side (shared network, redirect-loop caveat, full
authResponseHeaders, request walk-through).
2026-08-11 12:00:00 +00:00
Thore Cimbal a661ceda11 docs: prepare Authentik forward-auth for the Docusaurus wiki (not deployed)
Docusaurus is static and has no auth of its own, so the gate goes in Traefik in
front of it via an Authentik proxy outpost. Guide 09 carries the complete,
ready-to-apply config: the Authentik blueprint (forward_single proxy provider +
application + wiki-zugang group + policy binding) and the Overmind side (proxy
outpost container + Traefik forwardAuth middleware). Deliberately kept as a guide,
not merged into the applied blueprint ConfigMap: nothing goes live until the
wiki hostname (#0024) is settled and the outpost token (a credential) is created
by sorb. Dedicated outpost on Overmind so per-request checks stay local and only
the login redirect crosses the VPN. Indexed in the guides README.
2026-08-11 12:00:00 +00:00
Thore Cimbal eb5442e7bf docs: runbook for calls failing due to a missing profiles row
@apo could log in and message but no call would start — the click produced
zero server activity. Root cause: no Synapse profiles row, which makes every
displayname write 500 in _check_profile_size (NoneType), leaves the account
without a display name, and prevents the Element Call widget iframe from
initialising. Documents diagnosis (open_id_tokens=0 is the tell), the
cross-checked INSERT fix, and who is affected. Indexed in the troubleshooting
README.
2026-08-11 12:00:00 +00:00
Thore Cimbal ef04d86bc4 fix(mas): reject upstream logins whose localpart already exists
The Authentik upstream provider used claims_imports.localpart.on_conflict:
add, which links a newly registered upstream identity to an EXISTING local
account when the derived localpart collides. Combined with Authentik's
case-sensitive username uniqueness, any holder of an invitation token could
register a case variant of an existing name and be linked into that account,
including service accounts (draupnir, alerts, maintenance-notify) that have
no upstream link at all.

Set on_conflict: fail so a colliding localpart aborts provisioning instead.
Existing links are unaffected.
2026-08-11 12:00:00 +00:00
Thore Cimbal 151ef3c8d0 docs: index the concierge bot guide added earlier today
Wrote 08-concierge-gaeste-einladungen.md without adding it to the guide index - the same class of oversight this whole session has been about. Fixed within the hour instead of finding it later by accident.
2026-08-09 12:00:00 +00:00
Thore Cimbal 9d9a87b598 docs: fix theme count and names, stale since 2026-08-06
Said 7 themes, listed Gruvbox Dark and Wal by name - neither exists in element-values.yaml. Actual count verified against the live config: 17. Pointing at management/shared/branding.md as the single place that lists them with colours and light/dark labels instead of duplicating the list here, which is what let this drift in the first place.
2026-08-09 12:00:00 +00:00
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
Thore Cimbal f7412817c3 feat(client): restore the bug report button in local-only mode
Without bug_report_endpoint_url the whole section in Settings, Help and About is hidden, so a user has no way to obtain their logs at all. The element.io endpoint was deliberately removed on 2026-08-01 to stop logs flowing there.

The special value local brings the capability back without the outflow: the button reads Download logs, the dialog writes them to the device, and nothing is uploaded anywhere. Users can then attach them in Matrix, which is the path that is already in use.

Config only - no rebuild. Refs axion1337.chat/ThreadNet-Web#9
2026-08-09 12:00:00 +00:00
Thore Cimbal c341faf521 docs: repoint commit references after the history rewrite
The anonymisation rewrite of 2026-08-07 gave every touched commit a new SHA, leaving the references in these documents pointing at objects that no longer exist. The mapping was reconstructed from the backup branches and each pair verified by tree and commit message before substituting.

Prefix lookups were built for lengths 7 to 12 and any ambiguous prefix would have been skipped; none were ambiguous across all 251 pairs.
2026-08-09 12:00:00 +00:00
Thore Cimbal f0174decc1 fix(authentik): give the identification stage its required fields
The actual root cause of the failing blueprint, visible only after the KeyOf references were gone: the entry set recovery_flow alone, but the serializer validates the whole object and rejected it with 'When no user fields are selected, at least one source must be selected'.

All other fields are listed on purpose even though they match the model defaults - whatever the serializer does not receive is reset to its default, so a blueprint naming a single field can silently flip unrelated settings. Values are taken from the live database.

Refs axion1337.chat/axion1337.chat-gitops#60
2026-08-07 12:00:00 +00:00
Thore Cimbal 3c6f3645b3 fix(authentik): resolve recovery flow via Find instead of KeyOf
Adding name and title was necessary but not sufficient - the blueprint kept failing. Running ak apply_blueprint by hand showed why the real error was never visible: when authentik logs a failed entry it calls str() on the KeyOf object, whose __repr__ resolves against an EMPTY blueprint and raises the very same exception. The logging path masks the actual validation error.

KeyOf also couples every stage binding to that one entry succeeding in the same run. Find resolves against the database instead, which is what the neighbouring blueprints in this same ConfigMap already do, and the flow exists.

Refs axion1337.chat/axion1337.chat-gitops#60
2026-08-07 12:00:00 +00:00
Thore Cimbal 1b35766ad3 fix(authentik): make matrix-recovery blueprint apply again
The flow entry only set designation. FlowSerializer requires name and title as well (neither has a model default), so validation failed, the entry never got a model instance, and every following KeyOf matrix_recovery_flow reference failed with it. The blueprint had status error on every run.

Corroborated inside the same ConfigMap: matrix-invitation-flow sets name and title and applies successfully.

Values are taken from the live database so nothing changes on the page users see. The typo in mail-adress is left as is - changing user-facing text would be a separate decision.

Closes axion1337.chat/axion1337.chat-gitops#60
2026-08-07 12:00:00 +00:00
Thore Cimbal a81ea0dd2e Doku: MFA-Pflicht fuer Admins im Authentik-Leitfaden (gitops#57)
Dazu eine Aussage korrigiert, die seit heute nur noch halb stimmt: "2FA-Selbsteinrichtung optional (not_configured_action=skip)" gilt weiterhin fuer Mitglieder, aber nicht mehr fuer Admins.

Festgehalten sind vor allem die beiden Fallen: configure statt deny (deny sperrt Admins aus, ohne Weg zurueck ausser ueber den Cluster) und last_auth_threshold gegen die doppelte Abfrage. Dazu die wichtigste - stimmt der Gruppenname nicht, greift die Regel fuer niemanden und wirft dabei keinen Fehler.
2026-08-06 12:00:00 +00:00
Thore Cimbal f1d732afbe MFA-Pflicht fuer Admins mergen - Gruppe bestaetigt
sorb am 2026-08-06: "authentik Admins" ist die richtige Gruppe. Damit greift die Bindung.
2026-08-06 12:00:00 +00:00
Thore Cimbal 34442df75c ThreadNet-Web auf v0.4.3 - Call-Widget heisst aXion1337.Chat
Widget-Abhaengigkeit auf 0.19.2-threadnet.7. Umgesetzt ueber VITE_PRODUCT_NAME, ohne eine einzige geaenderte Quelldatei im Fork - upstream sieht die Variable an jeder Stelle vor.

Schliesst den dritten Punkt aus #54 ab.
2026-08-06 12:00:00 +00:00
Thore Cimbal e7f1b5d6f8 Authentik: MFA-Pflicht fuer Admins (gitops#57)
Eigene Validate-Stage hinter der vorhandenen, gebunden an die Admin-Gruppe. Fasst KEIN Authentik-Standardobjekt an: not_configured_action haengt an der Stage statt an der Bindung, eine Umstellung am Standard haette alle Mitglieder getroffen.

not_configured_action=configure statt deny - wer keinen Faktor hat, wird beim Login durch die Einrichtung gefuehrt statt ausgesperrt. Bei einem Admin-Konto gaebe es nach einem deny keinen Weg zurueck ausser ueber den Cluster.

last_auth_threshold=hours=1 verhindert die doppelte Abfrage: die Standard-Stage auf Ordnung 30 validiert bereits, wer einen Faktor hat; diese hier ueberspringt sich dann.

BEWUSST auf einem Branch: die Gruppe ist mit dem Authentik-Standard "authentik Admins" angenommen und von sorb noch nicht bestaetigt. Trifft sie nicht zu, greift die Regel fuer niemanden - das waere schlimmer als keine Regel, weil es sich sicher anfuehlt.
2026-08-06 12:00:00 +00:00
Thore Cimbal f5fe27336f Authentik: festhalten, wo ein neues Logo hingehoert
Wichtigster Punkt: solange branding_logo hier steht, gewinnt der Blueprint. Wer das Logo in Authentiks Oberflaeche auswaehlt, sieht es bis zur naechsten Reconciliation - danach ist es wieder weg. Ein neues Logo gehoert in diese Zeile.

Dazu die Liste der oeffentlich erreichbaren Assets und der Hinweis, dass die vorhandene Wortmarke im wiki-Repo liegt und von Hetzner aus nicht erreichbar ist - sie muesste erst mit dem Client ausgeliefert werden.
2026-08-06 12:00:00 +00:00
Thore Cimbal 191981aefe Authentik: Logo vorerst zurueck auf Authentiks eigenes
Unser 512-px-PNG rendert in der Anmeldemaske viel zu gross: Authentiks Default ist ein SVG, das sich der Box anpasst, ein PNG dagegen nimmt seine Naturgroesse. sorb entwirft ein passendes Logo.

Wichtig: explizit auf den Default gesetzt, nicht die Zeile geloescht - ein Blueprint mit state: present setzt weggelassene Felder nicht zurueck, der alte Wert bliebe in der DB stehen. Titel, Favicon und Hintergrund bleiben.
2026-08-06 12:00:00 +00:00
Thore Cimbal ebb1d4e2a7 Authentik-Brand mergen: Voraussetzung ist erfuellt
alpenglow.jpg wird seit 17:59 unter https://axion1337.chat/themes/element/img/backgrounds/ ausgeliefert (geprueft, 200). Der Flow-Hintergrund laeuft damit nicht mehr ins Leere.
2026-08-06 12:00:00 +00:00
Thore Cimbal 7a1710c78d ThreadNet-Web auf v0.4.2 - neues Titelbild
Elements lake.jpg weicht dem Alpengluehen (John Towner, Unsplash License), Danksagung entsprechend umgeschrieben. Image rohana.axion1337.de/sorb/threadnet-web:v0.4.2 aus Pipeline 192.

Voraussetzung fuer den naechsten Schritt in gitops#54: die ThreadNet-Brand in Authentik referenziert genau diese Bilddatei und liefe vorher ins Leere.
2026-08-06 12:00:00 +00:00
Thore Cimbal c1ee776070 Authentik: ThreadNet-Brand statt authentik-Standard
branding_title/logo/favicon/default_flow_background auf dem bestehenden Brand-Eintrag (domain authentik-default) - kein zweiter Brand, sondern derselbe, um den die default_application schon haengt.

Die Assets werden von der Client-Auslieferung referenziert statt in Authentik hochgeladen: sie liegen dort ohnehin, und eine zweite Kopie ist genau die Quelle, aus der spaeter Abweichungen entstehen. Ein ConfigMap-Mount scheidet aus - die drei Dateien reissen base64-kodiert das 1-MiB-Limit.

BEWUSST auf einem Branch: alpenglow.jpg existiert erst nach dem naechsten Client-Deploy. Auf main gemergt wuerde Flux das sofort ausrollen und die Anmeldeseite haette einen 404 als Hintergrund. Reihenfolge: erst Client-Image, dann dieser Merge.
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 f687b41441 Revert: UIFeature-Block wieder entfernt (falsche Praemisse)
Ich hatte registration/passwordReset/deactivate auf false gesetzt mit der
Begruendung, der Web-Client zeige Schaltflaechen, die ins Leere fuehren.

Gemessen hatte ich nur zweierlei: dass UIFeature in der Config fehlt und dass
der Homeserver Registrierung ablehnt. Dass daraus sichtbare Schaltflaechen
folgen, habe ich NICHT geprueft - sorb hat auf /#/login nachgesehen, dort gibt
es keinen Registrieren-Button. Element blendet ihn im SSO-Flow ohnehin aus.

Die Aenderung loeste damit ueberwiegend ein Problem, das es nicht gab - und
deactivate: false nahm zusaetzlich die Konto-Loeschung aus den Einstellungen,
die vorher sichtbar war. Das hatte niemand verlangt.

Zurueck auf den Stand davor. Sollen einzelne dieser Schalter bewusst gesetzt
werden, ist das eine eigene Entscheidung mit eigener Begruendung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 d7aca3bd0c element-web: Schaltflaechen ausblenden, die ins Leere fuehren (ThreadNet-Web#1)
Der Web-Client zeigte Registrierung, Passwort-Reset und Konto-Loeschung an - alle
drei funktionieren auf dieser Instanz nicht:

- Registrierung ist am Homeserver abgeschaltet (gemessen: M_FORBIDDEN,
  'Registration has been disabled. Only m.login.application_service
  registrations are allowed.')
- Login laeuft ausschliesslich ueber SSO (Flows: m.login.sso, m.login.token) -
  Passwoerter liegen in Authentik, nicht im Homeserver
- Konto-Loeschung laeuft ebenfalls ueber die Identitaetsverwaltung

Der Desktop-Client blendet die drei seit jeher aus; nur die Web-Config hatte
UIFeature gar nicht gesetzt. Damit sahen Web-Nutzer Angebote, die entweder in
einer Fehlermeldung enden oder am eigentlichen Weg vorbeifuehren - und
Registrierung widerspricht ausserdem der Vision-Entscheidung 'kontrolliert
wachsend, jeder Eintritt wird freigegeben'.

Chirurgisch eingefuegt, 17 Themes unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 0f28acb626 element-web: Prod auf v0.4.1
Nachtrag - der Bump war im vorigen Commit an einer falschen Einrueckung im
Suchtext gescheitert (8 statt 10 Zeichen, derselbe Fehler wie beim v0.4.0-Bump).
Diesmal zeilenbasiert statt ueber einen mehrzeiligen Suchtext.

Bringt: Tab-Titel ThreadNet, favicon.ico, Fehlerseite und Desktop-Hinweis mit
eigener Marke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 712cd430bf element-web: v0.4.1 + logo_link_url auf die eigene Instanz
Hebt Prod auf v0.4.1 (Tab-Titel, favicon.ico, Fehlerseite, Desktop-Hinweis).

Dazu logo_link_url: Elements Default zeigt auf element.io - ein Klick aufs Logo
haette also von unserer Anmeldemaske weggefuehrt. Zeigt jetzt auf die eigene
Instanz. Reine Config, wirkt sofort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 0213b0fe58 element-web: Auth-Logo auf die ThreadNet-Marke (ThreadNet-Web#7)
Auf der Anmeldemaske stand weiterhin Elements gruenes Logo - meine Icon-Arbeit
deckte nur vector-icons/ ab, also Favicon, PWA und App-Icon. Das Logo IM Client
kommt aus einer anderen Quelle: AuthHeaderLogo.tsx und HomePage.tsx lesen
branding.auth_header_logo_url aus der Config und fallen sonst auf
themes/element/img/logos/element-logo.svg zurueck.

Das ist reine Konfiguration - kein Rebuild noetig, die ConfigMap reicht.

Als Ziel bewusst vector-icons/512.png statt einer neuen Datei: Die liegt bereits
ausgeliefert (verifiziert, HTTP 200) und stammt aus derselben Quelle wie alle
anderen Icons. Eine zweite Logo-Datei koennte davon abdriften - genau die Falle,
die in shared/branding.md steht.

Randbedingung, die die Wahl einschraenkt: webpack kopiert aus res/ nur themes/**
und vector-icons/** nach webapp. Ein Logo unter res/img/ waere gar nicht per URL
abrufbar.

Chirurgisch eingefuegt, YAML und eingebettetes JSON validiert, 17 Themes
unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 b51a07b068 element-web: Prod auf v0.4.0 - Rebrand wird sichtbar
Hebt das Image von v0.3.0 auf v0.4.0. Damit sind die zentrierten Icons, die
Markenfarbe #ed4f4c in der Browser-/PWA-Leiste und die About-Attribution
'ThreadNet — powered by Element' unter der Client-Version live.

Der Weg ist der in der ThreadNet-Web-CI dokumentierte: Tag pushen -> docker_web
baut und pusht rohana.axion1337.de/sorb/threadnet-web:v0.4.0 -> Tag-Bump hier
deployt es. docker_web lief in Pipeline 176 gruen (101 s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 c7d4b6f376 CLAUDE.md: Uebergabe-Issue-Ausnahme zurueckgebaut (LABNET-03)
Die Ausnahme 'deploy-handover issues stay on the Gitea tracker' ist am
2026-08-02 entfallen - die Issues sind nach git.lab umgezogen, der Gitea-Tracker
ist leer. Damit stimmt jetzt auch der Satz weiter oben, dass kein Repo mehr auf
Gitea bearbeitet wird: Issues leben ohne Ausnahme auf git.lab, geschrieben wird
dort nur noch von der TURN-Rotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 42632d6b2a ci: Einrichtungsstand im Kopfkommentar nachgezogen
Schedule ist angelegt und zweimal probegelaufen; offen bleibt nur das
CANONIZE_TOKEN, jetzt mit Issue-Verweis (gitops#51) statt einer allgemeinen
Anleitung. Auch die beiden Probelaeufe sind vermerkt - Pipeline 159 hat die
fehlende Lab-CA aufgedeckt, 161 lief gruen durch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 52573634df ci: Lab-CA mitliefern, 'fetch origin' einsparen (Fund aus Pipeline 159)
Der Probelauf des neuen Jobs ist an genau der Stelle gescheitert, an der man es
nicht vermutet: 'git fetch gitea' lief durch (Gitea ist oeffentlich lesbar),
'git fetch origin main' dagegen brach ab mit 'unable to get local issuer
certificate'. Der Runner stellt die Lab-CA nur seinem eigenen Klon-Schritt
bereit, nicht dem Job-Script - der Klon oben im Log gelingt, der eigene
git-Aufruf zwei Zeilen spaeter nicht.

Zwei Aenderungen:

1. Die CA-Kette liegt jetzt als ci/lab-ca-chain.crt im Repo und wird per
   GIT_SSL_CAINFO gesetzt (CI_SERVER_TLS_CA_FILE zuerst, falls ein spaeterer
   Runner sie doch liefert). Ein CA-Zertifikat ist oeffentlich, kein Geheimnis -
   dieselbe Loesung wie im wiki-bookstack-Repo. Verifiziert: die Kette
   (aXionLabs Root + Intermediate) validiert https://git.lab.

2. 'git fetch origin main' faellt ganz weg. Der Runner hat main mit voller
   Historie schon ausgecheckt, CI_COMMIT_SHA ist dessen Stand - ein Netzaufruf
   weniger und eine Fehlerquelle weniger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 0a07da260f ci: Token-Pruefung ans Ende, damit der Leerlauf gruen bleibt
Vorher brach der Job ohne CANONIZE_TOKEN sofort ab - auch dann, wenn gar keine
Rotation offen war. Das haette taeglich eine rote Pipeline erzeugt, bis das
Token existiert, und ausgerechnet die Gitea-Strecke ungetestet gelassen.

Jetzt laeuft der Job erst bis zur Feststellung 'nichts zu tun' durch (verifiziert
dabei Fetch und Erkennung) und verlangt das Token nur, wenn wirklich gepusht
werden muss. Rot wird die Pipeline dann, wenn es zaehlt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 316178bd51 TURN-Rotation automatisch kanonisieren + Begruendung der Gitea-Kopie
Drei Dinge, die zusammengehoeren.

1. Falscher Satz raus. 'there is no direct-to-Gitea exception left' stand seit
   eff643e (2026-08-02, von mir) achtzehn Zeilen ueber einem Absatz, der eine
   laufende Ausnahme beschreibt - der Wiki-Umzug hatte die letzte Ausnahme auf
   REPO-Ebene beseitigt, ich hatte das zu 'gar keine mehr' verallgemeinert.

2. Das Warum der Gitea-Kopie ergaenzt. Bisher stand nur der Mechanismus da
   ('the cluster pulls from Gitea'), nicht der Grund: git.lab haelt die
   Bauplaene, Gitea eine Kopie, die der Cluster OHNE verfuegbares Lab erreicht.
   Ohne diese Begruendung sieht der Aufbau nach Altbestand aus - eine spaetere
   Session koennte die Flux-Quelle auf git.lab 'geradeziehen' und genau die
   Lab-Unabhaengigkeit zerstoeren, fuer die sie da ist. Steht jetzt als
   ausdrueckliche Warnung in beiden CLAUDE.md.

3. Den monatlichen Handgriff abgeschafft. Der Rotations-CronJob laeuft im
   Cluster, erreicht git.lab nicht und pusht nach Gitea; von dort musste die
   Rotation bisher per Hand ueber git.lab zurueck. Wird das vergessen,
   ueberschreibt der naechste Mirror-Push sie und Flux spielt still das ALTE
   Shared Secret wieder ein - ein Fehler ohne Symptom.

   Der Schedule-Job canonize_rotation holt jetzt taeglich jeden
   turn-secret-rotation-*-Branch von Gitea, der nicht in main steckt, merged
   und pusht ueber git.lab. Taeglich statt monatlich zum Rotationstermin, weil
   ein monatlicher Lauf genau einen Versuch haette.

   Faellt etwas aus dem Rahmen - Merge-Konflikt oder ein Secret ohne ENC[ -,
   bricht der Job ab und pusht NICHTS. Die rote Pipeline ist der Alarm; ein
   zusaetzlicher Termin waere wieder ein Todo, das man vergessen kann.

Verifiziert: YAML parst, alle elf Script-Bloecke sind gueltige sh-Syntax, und
die Kernlogik gegen den echten Repo-Stand durchgespielt - beide vorhandenen
Rotations-Branches werden korrekt als 'schon in main' uebersprungen.

Noch offen (braucht Rechte, siehe Dateikopf): Project Access Token als
CANONIZE_TOKEN hinterlegen und den taeglichen Schedule anlegen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 42da25d0a6 ci: Verweis auf das umbenannte Backlog-Repo im Kopfkommentar korrigiert
'siehe Backlogs CFGMON-11' - das Repo heisst seit dem PM-Framework-Umbau
management (ADR-0005). Zu CFGMON-11 gibt es kein Issue, die Arbeit war beim
Umzug abgeschlossen; deshalb datiert statt verlinkt. Reiner Kommentar, die
Job-Definition ist unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 bb5778e121 element-values: Herkunft der Theme-Paletten im Dateikopf vermerkt
Die Datei ist der Ort, an dem jemand Themes aendert - und der Ort, an dem nicht
stand, woher die Farben kommen. Genau so entstand die erfundene Fassung. Drei
Zeilen Kommentar mit Quelle, Verweis auf shared/branding.md und Hinweis auf die
zweite Kopie in ThreadNet-Web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 b10b607d70 element: die zehn Themes nach den Originaldefinitionen neu gebaut
Grundlage sind jetzt die Definitionen aus Anthropics theme-factory-Skill
(github.com/anthropics/skills/skills/theme-factory) statt meiner Interpretation
der Namen. Meine erste Fassung traf bei fast allen daneben - am deutlichsten bei
Sunset Boulevard, wo ich kraeftiges Koralle/Pink baute statt der vorgegebenen
Terrakotta-Palette #e76f51/#f4a261/#e9c46a/#264653.

Ob ein Theme hell oder dunkel gemeint ist, steht in den Beschreibungen teils
widerspruechlich ('Warm Sand - backgrounds' bei einem Theme, dessen Showcase-Seite
dunkel ist). Deshalb aus theme-showcase.pdf gemessen: sieben der zehn sind hell,
nur Sunset Boulevard, Golden Hour und Desert Rose dunkel. Vorher hatte ich sechs
faelschlich als dunkel angelegt.

Ableitung je Theme: die vier Originalfarben als Akzent/Sekundaer/Highlight/Text,
Flaechenabstufungen daraus gemischt, Username-Farben als Mischungen derselben
Palette - damit bleibt jedes Theme in sich stimmig.

Chirurgisch: nur die colors-Bloecke und is_dark der zehn Themes (299 Zeilen gegen
299), YAML validiert, die uebrigen sieben Themes unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 ac0afaf3d9 element: Sunset Boulevard auf gedaempftes Terrakotta-Beige umgebaut
Meine erste Auslegung des Namens waren kraeftige Sonnenuntergangstoene (Koralle,
Pink, Weinrot) - gemeint war die Hollywood-Hills-Richtung. sorbs eigene Wahl in
der BookStack-Oberflaeche macht das eindeutig: fuenf seiner sieben Farbwerte sind
der Coolors-Satz #264653/#2A9D8F/#E9C46A/#F4A261/#E76F51.

Genau der liegt jetzt auch dem Element-Theme zugrunde: Charcoal als Grundton,
Burnt Sienna als Akzent, Sandy Brown und Saffron als Sekundaerfarben, Persian
Green fuer Auswahl und Hervorhebung, warmes Beige als Text.

Chirurgisch: nur der colors-Block dieses einen Themes ersetzt (31 Zeilen gegen
31), YAML validiert, die uebrigen 16 Themes unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-02 12:00:00 +00:00
Thore CimbalandClaude Fable 5 b15a9f578c element: 11 neue Themes - aXion1337 Light plus zehn Paletten
aXion1337 Light ist das exakte helle Gegenstueck zum Lieblingstheme: 'aXion1337
Dark' ist Gruvbox Dark (Farben und die acht Username-Farben sind die
Gruvbox-Palette), also ist Gruvbox Light das stilistische Analogon - gleiche
Rollenverteilung, gleiche Akzentfamilie, nur die Helligkeitsachse gespiegelt.

Dazu zehn benannte Paletten mit vollem Schluesselsatz (24 Farben inkl.
username-colors): Ocean Depths, Sunset Boulevard, Forest Canopy, Modern
Minimalist, Golden Hour, Arctic Frost, Desert Rose, Tech Innovation, Botanical
Garden, Midnight Galaxy - vier davon hell.

Chirurgisch eingefuegt (Lehre vom 2026-07-30): die YAML wurde nicht geparst und
nicht neu serialisiert, nur Text vor der schliessenden Array-Klammer ergaenzt.
Diff-Bilanz 0 entfernte / 427 neue Zeilen, YAML und eingebettetes JSON validiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 cdecb006d4 Doku-Topologie: Wiki liegt auf git.lab, wiki.lab als gemeinsame Leseflaeche
Warnung ergaenzt, dass der wiki-Branch ein ueberholter Mai-Abzug ist und nicht
die gepflegte Fassung (ADR-0006 im management-Repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 ba9783fa08 CLAUDE.md: Verweis auf kanonische Arbeitskonventionen im management-Repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 c8fc91ff1d CLAUDE.md: Backlogs-Repo heisst jetzt management (Framework-Umbau 2026-08-01)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 8331bb212d CLAUDE.md: Topologie-Update nach Backlogs-Umzug + Issue-Migration (gitops#48)
- Backlogs jetzt git.lab-kanonisch (Gitea = Push-Mirror), nur das Wiki bleibt direkt-Gitea
- Issues leben auf git.lab; gitops-Nummernverschiebung dokumentiert (Gitea#48 -> GitLab#46)
- Ausnahme Deploy-Uebergabe-Issues (CFGMON erreicht git.lab nicht)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 018a4b7d83 Merge: element-web auf kanonisches CI-Image v0.3.0 (Freigabe sorb, ThreadNet-Web#8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 dfe7ff9fc0 element-values: Originalformat wiederhergestellt, Aenderung chirurgisch
Der naechtliche YAML-Redump (9aa42f9/e963203) hatte Kommentare und
Formatierung der Datei zerstoert - semantisch war er korrekt (verifiziert),
aber wartungsfeindlich. Jetzt: Original von c631b0a + exakt die 5 Zeilen
aus ThreadNet-Web#1 (4 Feature-Flags + setting_default). Effektive Config
byte-identisch mit dem bereits deployten Stand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 d4f3fc4c97 element-web: Bump auf kanonisches CI-Image v0.3.0 (ThreadNet-Web#8)
Exec-Bit + Call-Widget + config.json im frischen Image verifiziert
(docker pull + Inspektion). Ersetzt die Derivat-Linie v0.3.0-clientscan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 af63755f1e element-web: Client-Feature-Defaults an Desktop angeglichen (ThreadNet-Web#1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 44d3e7dae1 CLAUDE.md: Topologie-Ausnahme fuer Rotations-PRs + .gitlab-ci.yml in Struktur
Der TURN-Rotations-CronJob erreicht git.lab nicht und oeffnet seinen PR
weiter auf Gitea - dokumentiert, wie er kanonisch zu mergen ist
(Beispiel 640c934 vom 2026-08-01), inkl. Mirror-Force-Sync-API.
Karpathy-Guidelines unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 11f97b935f element-web: Feature-Defaults an Desktop-Config angeglichen (ThreadNet-Web#1)
Web-Deploy war Teilmenge der Desktop-Defaults: video_rooms, group_calls,
element_call_video_rooms, new_room_decoration_ui + setting_default
feature_group_calls ergaenzt. Discord-Raumliste war schon beidseitig an.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 717638d12c chore(coturn): TURN-Rotation 2026-08-01 uebernommen (kanonischer Weg via git.lab)
Automatischer Rotations-PR (Gitea #46) darf seit der Topologie-Umstellung
nicht mehr auf dem Mirror gemergt werden - Branch vom Mirror geholt und
hier kanonisch gemergt; der Push-Mirror traegt den Stand zurueck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore Cimbal 3c26cf2504 CLAUDE.md bearbeiten 2026-08-01 12:00:00 +00:00
turn-secret-rotation 60248bfd92 chore(coturn): automated TURN shared-secret rotation
Scheduled rotation (Issue #38). New secret generated, re-encrypted with
the scoped rotation age key, checksum/rotated-at annotations bumped so
Flux restarts coturn + synapse-main on merge. Please review and merge.
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 228807fcc8 monitoring: doppelten node-exporter entfernt (gitops#45)
Das prometheus-node-exporter-HelmRelease (hostNetwork) kollidierte auf
dem Host mit dem etablierten systemd-Exporter auf Port 9100 und konnte
seit Anlage nie binden (4883 Restarts, CrashLoopBackOff). Host-Metriken
kommen weiterhin vom systemd-Dienst, den CFGMON direkt via 10.0.0.2:9100
scrapt - das DaemonSet haette sie nur dupliziert. Alloy-Scrape auf den
verwaisten Service ebenfalls entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore CimbalandClaude Fable 5 6f761da1a4 monitoring: Alloy-Synapse-Scrape auf den echten Metrics-Port 9001 korrigiert
Der Service matrix-stack-synapse-main exponiert 8008/9093/8080/9001
(synapse-metrics = 9001) - Port 9000 existiert nicht, der Scrape war
seit Anlage tot. Aufgedeckt durch den ersten TargetDown-Alarm des neuen
Alertings (gitops#32) direkt nach dem Deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
2026-08-01 12:00:00 +00:00
Thore Cimbal 8ad921519f docs: Session-Log 2026-07-30..08-01 (CI-Umzug, Windows-Strecke, Registry, Rueckbau) 2026-07-31 12:00:00 +00:00
Thore Cimbal b28ef2c97a docs: Repo-Topologie dokumentiert - git.lab kanonisch, rohana Mirror + Flux-Quelle 2026-07-31 12:00:00 +00:00
Thore CimbalandClaude Fable 5 5e46a24e7d ci: Verifikations-Job nach GitLab portiert, Gitea-Workflows entfernt (CFGMON-11)
Der deploy-on-push-Check (YAML-Manifest-Pruefung, SOPS-ENC-Kontrolle) laeuft
jetzt als leichter Job in der Lab-GitLab-CI; milestone-release.yml war seit
jeher toter Code (kein Runner, Releases wurden manuell per API erstellt,
siehe Issue #33). Flux bleibt unberuehrt - es zieht weiterhin vom
Gitea-Mirror. Damit verliert der Gitea-Runner builder-1 seinen letzten
aktiven Konsumenten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:00:00 +00:00
Thore Cimbal 5aca738f55 ci: comparison push for ThreadNet-Web actions-dispatch diagnosis 2026-07-30 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 932443df63 docs: fix stale status claims, move CLAUDE.md into repo, add host-config note
Documentation audit across the repo found several places still describing
finished work as pending or in-progress (Authentik Stage 2, Element Call
fork, NetworkPolicies in TASKS.md's own Next Steps section, the Boje
troubleshooting entry). Also moves CLAUDE.md from the untracked parent
directory into the repo root and brings its content up to date, and
documents the new host-config/ pattern in README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 376772078a feat: pre-update maintenance notifications via mail + matrix (Issue #24)
unattended-upgrades was already active on the host, just never documented
or closed. Adds a generic, reusable systemd timer + script that fires
before the daily update window and notifies via email and a Matrix thread
reply if any packages are actually pending - reusing the mas-cli bot
account pattern established for Draupnir.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:00:00 +00:00
Thore Cimbal 100b46ec91 docs: close Issue #13, moved to ThreadNet-Web#4 (client-side 2FA link) 2026-07-29 12:00:00 +00:00
Thore Cimbal 2803fb384c docs: update TASKS.md with extended Issue #19 scope and new Issue #44 2026-07-29 12:00:00 +00:00
Thore Cimbal 11a079db57 docs: correct Electron/desktop claim, add hostile-sender test result (Issue #19) 2026-07-29 12:00:00 +00:00
Thore Cimbal e094c4ab29 docs: document client-side scan patches and Electron/desktop caveat (Issue #19) 2026-07-29 12:00:00 +00:00
Thore Cimbal 5ca8f00d58 feat: deploy full ThreadNet-Web rebuild with client-side content scanning
First full rebuild deployed to production (previous releases used the
widgets/element-call/-only patch workaround, fixed but not cut over in
Issue #12). Includes the new client-side scan hooks (DecryptFile.ts,
ContentMessages.ts) that cover encrypted rooms, which the server-side
Synapse module (Issue #19) structurally cannot see.
2026-07-29 12:00:00 +00:00
Thore Cimbal 7b19586179 feat: deploy client-side ClamAV scan service for encrypted rooms
Synapse's own media-scanning module (Issue #19) can never see E2EE
attachment content - a structural limit, not a bug. This adds a small
standalone HTTP wrapper around the same ClamAV instance, reachable
from browser JS at /_scan, so the ThreadNet-Web client fork can scan
plaintext both before encrypting/uploading and after downloading/
decrypting - covering both directions regardless of room encryption.
Auth via Synapse's own /whoami endpoint, no separate auth system.
2026-07-29 12:00:00 +00:00
Thore Cimbal 4ca87a68c7 docs: document real ClamAV/Synapse module setup and test results (Issue #19) 2026-07-29 12:00:00 +00:00
Thore Cimbal 7ac584d27b fix: use Twisted networking instead of asyncio in ClamAV module
Synapse runs on Twisted's reactor, not asyncio's event loop - the
original asyncio.open_connection/wait_for calls failed immediately
with "RuntimeError: no running event loop", silently fail-opening
every scan (confirmed live: EICAR test file passed through unscanned).
Rewritten using twisted.internet.endpoints.HostnameEndpoint/
connectProtocol and a custom Protocol for the INSTREAM conversation.
2026-07-29 12:00:00 +00:00
Thore Cimbal 9db1ee6f45 feat: real media antivirus scanning via custom Synapse module (Issue #19)
Deploys ClamAV and a small stdlib-only Synapse spam-checker module
implementing check_media_file_for_spam over clamd's INSTREAM protocol.
Unlike the originally-considered matrix-content-scanner proxy (which
needs client-side cooperation neither Element Web nor Element X
provide), this hooks Synapse's own module API directly - transparent
to every client for unencrypted media. No custom Synapse image needed:
the module is mounted via a ConfigMap onto PYTHONPATH using the ESS
chart's extraVolumes/extraVolumeMounts/extraEnv support. Fails open on
scanner errors so a ClamAV outage can't block all uploads.
2026-07-29 12:00:00 +00:00
Thore Cimbal 2e53387650 docs: mark Draupnir moderation bot done in TASKS.md (Issue #18) 2026-07-29 12:00:00 +00:00
Thore Cimbal e659242990 docs: add moderation/content-scanning deployment guide (Issue #18) 2026-07-29 12:00:00 +00:00
Thore Cimbal bb51047e1d feat: enable experimentalRustCrypto for Draupnir management room
Testing E2EE support for the management room per user request - marked
"not considered production safe" upstream, verifying no regressions
before deciding to keep it.
2026-07-29 12:00:00 +00:00
Thore Cimbal f58add72ae fix: pass explicit --draupnir-config path (v3.x dropped auto-discovery)
Confirmed by extracting dist/config.js from the actual image - v3.x's
getConfigPath() only checks --draupnir-config/--mjolnir-config CLI
flags, no longer NODE_CONFIG_DIR/node-config auto-discovery.
2026-07-29 12:00:00 +00:00
Thore Cimbal 5e16719bfe fix: pin Draupnir to v3.1.0 instead of v2.9.0
initialManager's automatic management-room creation (no manual room
setup needed) only works on v3.1.0+ - v2.9.0 crashed on first startup
trying to join a room that was never created.
2026-07-29 12:00:00 +00:00
Thore Cimbal 7cd177f744 feat: deploy Draupnir moderation bot (Issue #18)
Bot account registered via mas-cli, compatibility token issued and
stored via sops. Adds an explicit NetworkPolicy allow-rule since
Draupnir needs to reach Synapse's client-server API in-namespace,
which the existing default-deny policy would otherwise silently block.
2026-07-29 12:00:00 +00:00
Thore Cimbal 5c3a7b798a docs: document VP9/AV1 test outcome and revert (Issue #11) 2026-07-29 12:00:00 +00:00
Thore Cimbal 23c66c97b8 revert: roll back VP9/AV1 codec test (Issue #11)
Real testing (Safari + Firefox, fresh rejoins) confirmed VP9 selection
always falls back to VP8 - our fork's buildPublishOptions() forces
simulcast:true unconditionally, which breaks LiveKit's SVC negotiation
path that vp9/av1 require. This is the same failure mode originally
suspected in the 2026-07-28 incident; the "LiveKit handles this
automatically" assumption from doc research did not hold up in
practice. Reverting to the known-safe VP8/H.264/H.265 SFU allow-list
and widget image. Root cause documented in Issue #11 for a future,
scoped fix (conditional simulcast vs. scalabilityMode per codec).
2026-07-29 12:00:00 +00:00
Thore Cimbal 9fa88bda5c test: deploy VP9/AV1-enabled element-call widget (Issue #11)
Patches the re-enabled VP9/AV1 codec dropdown (threadnet-call) onto the
existing v0.1.0 base image. Test-only tag - to be reverted or made
permanent depending on real call test results.
2026-07-29 12:00:00 +00:00
Thore Cimbal 105194d450 feat: enable VP9/AV1 codecs at LiveKit SFU (Issue #11 test)
Appends video/VP9 and video/AV1 to the SFU's room.enabled_codecs
allow-list via matrixRTC.sfu.additional, keeping all currently
negotiated codecs unchanged. Opt-in test per Issue #11 - codecs
become selectable, not the new default.
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 2aed6e4932 docs: update ThreadNet-Web build-bug status to fixed (issue #12)
Both docs/TASKS.md and the deployment guide still described the
full-rebuild blockers as an open, unfixed bug - only the wiki mirror had
been updated when #12 was actually closed. Added the fix summary to
both, plus a note about today's Video-tab/i18n/codec-list Element Call
changes in the deployment guide (previously only tracked in the wiki).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 174c58001b fix(element-call): limit codec dropdown to codecs the SFU actually supports
Live-verified via matrix-rtc-sfu logs: VP9 gets silently rejected and
falls back to VP8 (SFU's enabledPublishCodecs is [VP8, H264, H265], no
VP9/AV1). Dropdown now only offers those three; also fixed
screenShareCodec's default which was "vp9" (fork commit ac99cc65 in
threadnet-call).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 5f7fd8bc46 i18n(element-call): add German translations for camera/screen share settings
The MediaQualitySettings labels moved into the Video tab had no German
translations at all (en/app.json had all 10 keys, de/app.json had none) -
fork commit f13c4b43 in threadnet-call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 13385b68f2 feat(element-call): expose camera/screen share quality settings in Video tab
Moved from behind the Developer-mode toggle into the regular Video
settings tab (fork commit f61650c0 in threadnet-call) - these are just
resolution/framerate/bitrate/codec pickers, not actually developer-only
functionality, just accidentally buried where most users would never
find them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 ccf6856f42 docs: bring top-level README up to date
README described only the initial v0.1.0-era bootstrap - no mention of
Authentik, coturn/TURN, monitoring, NetworkPolicies, backups, or the
Element Web/Call forks, all of which have been live for months. Also
fixed: broken TOC links (pointed to Google search instead of anchors),
stale mas-secrets.sops.yaml / ess-mas-custom-secrets references (actual
names are mas-secret.yaml / ess-mas-values-secret).

Added pointers to CLAUDE.md, docs/TASKS.md, Gitea Releases (new SemVer
scheme), the wiki, and deployment guides, plus a note recommending the
devcontainer as the primary way to get local tooling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 eabde3747e fix(docs): make todo-tree detect markdown checkboxes, clean up stale TASKS.md backlog
todo-tree stopped triggering on docs/TASKS.md because the file was
restructured (2026-07-28 backlog migration) to use plain markdown
checkboxes with no literal TODO/FIXME/etc. keywords, which is all
todo-tree's default config matches on. Added the documented
todo-tree.regex.regex + [ ]/[x] tags configuration (see
Gruntfuggly/todo-tree wiki) so it actually detects checkbox items, plus
red/green highlighting for open vs done.

Also cleaned up 19 stale open checkbox items left behind by that same
migration - they duplicated content already tracked as individual Gitea
issues (in old pre-migration detail, not the established "-> Issue #N"
pointer format the rest of the file already uses), including two
(Database Backup Strategy, Synapse Media PVC Backups) for issues that
are actually already closed. Converted all to pointer format or removed
where closed. Replaced the stale M1-M7 milestone table (contradicted its
own file header - said M4 "In Progress" while the summary line above
already said 0 in progress) with a pointer to the new SemVer Releases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 2932ffffc3 fix(matrix-stack): shorten HelmRelease reconcile interval to 1m
Live-tested Issue #38's rotation: after merging the automated rotation
PR, coturn restarted quickly (Kustomization-level, 1m interval), but
synapse-main lagged behind since it depends on this separate HelmRelease
with its own 5m interval - a real (self-healing, but avoidable) window
where coturn had the new TURN secret and Synapse still had the old one,
which would reject each other's credentials/relayed media. Matching the
interval to production-apps's 1m tightens that window without needing
any new automation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore Cimbal 0f7ff1a896 Merge pull request 'chore(coturn): automated TURN shared-secret rotation' (#42) from turn-secret-rotation-20260728-192656 into main 2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 debf980d5f fix(turn-rotation): add hostAliases for private-network Gitea access
Job hung indefinitely on git clone - public-internet reachability to
rohana.axion1337.de from inside pods is currently down (related to
Issue #41's firewall/private-network findings), and the node-level
/etc/hosts workaround only helps containerd's image pulls, not processes
running inside pods (they resolve via CoreDNS + their own /etc/hosts, not
the node's). hostAliases routes this job's git/API traffic over the
private Hetzner network directly - verified working with a real pod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
turn-secret-rotation 802e09cd9c chore(coturn): automated TURN shared-secret rotation
Scheduled rotation (Issue #38). New secret generated, re-encrypted with
the scoped rotation age key, checksum/rotated-at annotations bumped so
Flux restarts coturn + synapse-main on merge. Please review and merge.
2026-07-28 12:00:00 +00:00
Thore Cimbal d742c99ba5 chore: set Gitea PAT for TURN secret rotation job 2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 ac7e65100d feat(coturn): add automated TURN shared-secret rotation CronJob
Closes issue #38's automation half (architecture fix + first rotation
already landed in earlier commits this session). Monthly CronJob
(rohana.axion1337.de/sorb/axion-secret-rotation:v1 - alpine + git/sops/
jq/age) that:

- generates a new secret
- re-encrypts coturn-secret.yaml and synapse-turn-secret.yaml using the
  scoped rotation-only age key (added as an additional recipient in an
  earlier commit) - never touches the repo's master sops-age key
- bumps the turn-secret-checksum (HelmRelease annotation) and rotated-at
  (coturn Deployment annotation) so merging actually restarts both
  consumers, reusing the existing checksum-annotation pattern already in
  this repo rather than inventing a new mechanism
- opens a Pull Request rather than pushing straight to main - a human
  reviews and merges, keeping a checkpoint before production picks up new
  credentials while still automating the tedious coordination work

Needs a Gitea PAT (repo write scope) filled into
turn-secret-rotation-secret.yaml's gitea-token key before first use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 be95bd921c feat(coturn): add scoped age recipient for automated rotation job
Adds a second age recipient (dedicated keypair, private key never touches
the repo) to just these two files, so the upcoming rotation CronJob can
decrypt/re-encrypt them without needing the repo's master sops-age key,
which can decrypt every secret in the repo. Verified: master key still
decrypts both files; the new scoped key can decrypt both files but
correctly cannot decrypt an unrelated secret (authentik-secret.yaml).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore Cimbal 7795f89fc9 docs(coturn): note the one-time manual rollingUpdate fix instead of an ineffective YAML null 2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 9fa947cc21 fix(coturn): explicitly null rollingUpdate field alongside Recreate strategy
Server-side apply left the old RollingUpdate defaults (maxSurge/maxUnavailable)
on the live object from before strategy.type was ever set explicitly, which
the API rejects when combined with type: Recreate. Explicit null clears it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 233b13f972 fix(coturn): use Recreate deployment strategy for hostNetwork pod
Discovered live: the default RollingUpdate strategy tries to schedule the
new pod before removing the old one, which can never succeed for a
hostNetwork Deployment on a single-node cluster (both pods would need the
same node ports 3478/5349 simultaneously). Surfaced by the rotated-at
annotation bump from the previous commit getting stuck in
FailedScheduling. Recreate kills the old pod first, freeing the ports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 51ea51352b fix(coturn): remove plaintext TURN shared secret, rotate leaked value
Issue #38 discussion surfaced a real bug: the TURN shared secret was
duplicated - correctly SOPS-encrypted in coturn-secret.yaml, but also
hardcoded in plaintext in synapse-values.yaml (a plain, non-SOPS
ConfigMap), visible in git history. Also found turn_user_lifetime is
86400000ms (24h), not "short-lived" as previously assumed - raising the
stakes of the leak somewhat.

Extracted the turn config block into its own dedicated SOPS-encrypted
Secret (synapse-turn-secret.yaml), wired via a second HelmRelease
valuesFrom entry (same pattern already used for ess-mas-values-secret).
Rotated the value while doing this, so the leaked plaintext secret is no
longer live anywhere.

Added checksum/rotated-at annotations (matrix-stack HelmRelease's
existing element-config-checksum patch gets a sibling turn-secret-checksum;
coturn's Deployment pod template gets a rotated-at annotation) so future
rotations actually restart both consumers - Kubernetes doesn't restart
running pods when a referenced Secret's content changes on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore Cimbal 9607687e4e docs: document node-level /etc/hosts entry for private-network registry access 2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 49005ed325 fix(backup): retry pg_dump connection to survive NetworkPolicy sync lag
Live testing showed pg_dump got "Connection refused" immediately at job
start, but a manual pg_isready/psql from an identically-labeled pod a few
seconds later succeeded fine every time. The cluster's NetworkPolicy
controller needs a brief moment to program a brand-new pod's IP into the
target's allowed ingress rules; a job that starts dumping instantly can
race that window. backup.sh now waits for pg_isready before each pg_dump
(up to 15 tries / ~30s) instead of assuming connectivity is ready at
container start.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 ba270bce71 feat(backup): add nightly Borg backups to Hetzner Storage Box
Closes issues #6 and #15 (treated jointly, per combined analysis in their
comments). Two CronJobs, one per namespace, each pushing to its own Borg
repo on the newly booked Storage Box (u641795.your-storagebox.de:23, BX11
1TB) with its own repo passphrase - a leaked passphrase for one doesn't
expose the other:

- matrix: synapse-backup dumps the synapse + matrixauthenticationservice
  DBs (shared postgres, existing chart-generated POSTGRES_ADMIN_PASSWORD)
  and the Synapse media_store PVC (read-only mount)
- authentik: authentik-backup dumps the authentik DB (existing
  authentik-credentials pg-password)

Custom image (rohana.axion1337.de/sorb/axion-backup:v1, postgres:17-alpine
+ borgbackup + openssh-client - matches the live Postgres major version
exactly, unlike Alpine's stock postgresql16-client) pushed as a public
package, same pattern as the existing threadnet-web image (no
imagePullSecret needed).

SSH host key pinned via known_hosts ConfigMap (captured via ssh-keyscan
ahead of time) rather than trusting on first connect in an unattended job.
Retention: 7 daily / 4 weekly / 6 monthly via borg prune. Schedule: 03:00
and 03:15, offset to avoid resource contention.

NetworkPolicy: added each backup job's pod as an allowed source to its
namespace's existing postgres ingress rule (matrix's allow-ingress-postgres,
authentik's allow-ingress-authentik-postgresql from #37). Egress already
unrestricted in both namespaces, so no change needed for the outbound SSH
connection to the Storage Box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 2c5f77686d fix(authentik): scope authentik-postgresql NetworkPolicy to internal access
Closes issue #37. The Bitnami postgresql subchart's auto-generated
NetworkPolicy restricted the port (5432) but not the source - any pod in
any namespace could reach it, since additive NetworkPolicy rules can't
restrict an existing permissive one, only the chart itself controls it
(hence not touched during the original #10 rollout).

Disabled via postgresql.primary.networkPolicy.enabled: false and replaced
with a scoped policy allowing only authentik-server and authentik-worker
(same namespace) on 5432, matching the pattern already used for the
matrix namespace's postgres access. No metrics exporter is enabled for
this instance, so no monitoring-namespace rule is needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 414ada956a feat(authentik): capture MAS OIDC Provider/Application as a Blueprint
Closes issue #36. The OAuth2 Provider ("Matrix Authentication Service")
and its Application ("matrix" slug) linking Authentik to MAS were
originally clicked together by hand in the UI and existed nowhere as
code - unlike the flow fixes already captured in authentik-blueprints.yaml.
Losing the Authentik DB would have meant re-creating this from scratch,
including a fresh client_secret that MAS would no longer match.

The client_secret is read via !Env from AUTHENTIK_MAS_OIDC_CLIENT_SECRET,
sourced from a new key in the existing SOPS-encrypted authentik-credentials
Secret, rather than inlined into the blueprint ConfigMap (which isn't
itself encrypted). Value used is the actual live secret already in use,
read directly from the running Authentik DB - not a new/rotated one, so
this changes nothing about the current MAS<->Authentik pairing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 4a3d7c70a6 fix: restore correct file permissions, stop tracking .DS_Store
Almost every tracked file in the repo had drifted to mode 777 on disk
(only files created fresh this session were unaffected), and a chunk of
that drift had already been committed as spurious +x bits on plain
YAML/Markdown files (authentik.yaml, kustomization.yaml, coturn.yaml,
element-server-suite.yaml, TASKS.md, install.md, etc.) - none of these
need to be executable. Restored to 644 for regular files, 755 only for
actual scripts (postCreateCommand.sh, docker-init.sh, install-hooks.sh,
pre-commit hook, element-setup-linux.sh).

Also found element-setup-macos.command was missing +x despite having a
shebang and being meant for double-click execution on macOS - fixed.

Added .gitignore for .DS_Store and .claude/ and stopped tracking the five
.DS_Store files that had been committed by accident.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
Thore CimbalandClaude Sonnet 5 6973a8dbc7 fix(devcontainer): repair broken build and add docker socket permission fix
The devcontainer could never actually be built successfully - verified by
building it from scratch for the first time in a while. Found and fixed six
issues:

- kubectl: apt.kubernetes.io was deprecated/shut down by Google in 2023,
  switched to the official successor repo pkgs.k8s.io
- docker-ce-cli: apt line hardcoded arch=amd64, breaking the build on
  Apple Silicon; resolved dynamically via dpkg --print-architecture
- useradd -G docker failed because only the Docker CLI (no daemon) is
  installed, so no package ever creates the docker group; added explicit
  groupadd
- oh-my-zsh install had a nested-quoting bug that made the RUN step fail;
  simplified to download-then-run instead of one nested `su -c "sh -c ..."`
- sops binary was hardcoded to linux.amd64, only working on arm64 by luck
  via Docker Desktop's QEMU emulation; resolved dynamically like docker-ce
- docker.sock was mounted but unusable (permission denied) since the
  container's docker group GID never matched the host socket's GID; added
  a root entrypoint (docker-init.sh) that reconciles this at container
  start, then drops to the vscode user via gosu

Also fixed two stale mas-secrets.sops.yaml references (actual filename is
mas-secret.yaml) in README.md and postCreateCommand.sh, set the vscode
user's default shell to zsh (oh-my-zsh was installed but never used by
default), and documented all of the above plus a build+run verification
snippet in README.md so this class of drift is caught before it goes
unnoticed again.

Verified end-to-end: cold `docker build --no-cache`, then a real container
run against the actual mounted kubeconfig, age key, and docker socket -
kubectl reaches the live cluster, sops decrypts a real secret, and docker
ps talks to the real daemon as the vscode user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:00:00 +00:00
97 changed files with 6159 additions and 423 deletions
Vendored
BIN
View File
Binary file not shown.
+28 -10
View File
@@ -16,11 +16,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
zsh \
sudo \
openssh-client \
gosu \
&& rm -rf /var/lib/apt/lists/*
# Install kubectl
RUN curl -fsSLo /usr/share/keyrings/kubernetes-archive-keyring.gpg https://packages.cloud.google.com/apt/doc/apt-key.gpg && \
echo "deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main" | tee /etc/apt/sources.list.d/kubernetes.list && \
# Install kubectl (apt.kubernetes.io was deprecated/shut down by Google in 2023;
# pkgs.k8s.io is the current community-owned repo, versioned per k8s minor release)
RUN mkdir -p /etc/apt/keyrings && \
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.34/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.34/deb/ /" | tee /etc/apt/sources.list.d/kubernetes.list && \
apt-get update && apt-get install -y kubectl && \
rm -rf /var/lib/apt/lists/*
@@ -30,9 +33,10 @@ RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | b
# Install Flux CLI
RUN curl -s https://fluxcd.io/install.sh | bash
# Install sops
RUN SOPS_VERSION=$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | grep tag_name | cut -d '"' -f 4) && \
curl -sL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64 && \
# Install sops (arch resolved at build time, same reasoning as the Docker CLI step below)
RUN SOPS_ARCH=$(dpkg --print-architecture) && \
SOPS_VERSION=$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | grep tag_name | cut -d '"' -f 4) && \
curl -sL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${SOPS_ARCH} && \
chmod +x /usr/local/bin/sops
# Install age
@@ -40,17 +44,31 @@ RUN apt-get update && apt-get install -y age && \
rm -rf /var/lib/apt/lists/*
# Install Docker CLI (for interacting with Docker daemon)
# arch is resolved at build time so this works on both amd64 (cloud/CI) and arm64 (Apple Silicon) hosts
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
apt-get update && apt-get install -y docker-ce-cli && \
rm -rf /var/lib/apt/lists/*
# Create a non-root user 'vscode' for development
RUN useradd -m -s /bin/bash -G docker vscode && \
# groupadd is needed because only the Docker CLI (not the daemon) is installed above,
# so the 'docker' group is never created as a package side effect
RUN groupadd docker && \
useradd -m -s /bin/zsh -G docker vscode && \
echo "vscode ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/vscode
# Install oh-my-zsh for better shell experience
RUN su - vscode -c "sh -c '$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)' '' --unattended"
RUN curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o /tmp/install-omz.sh && \
su - vscode -c "sh /tmp/install-omz.sh --unattended" && \
rm /tmp/install-omz.sh
# Entrypoint runs as root to reconcile the docker group's GID against the mounted
# socket (see docker-init.sh), then drops to 'vscode' for the actual session/command.
# Stays root-owned at the PID 1 level; VS Code's own `docker exec -u vscode` sessions
# and the entrypoint's `gosu vscode` both end up correctly grouped either way.
COPY docker-init.sh /usr/local/bin/docker-init.sh
RUN chmod +x /usr/local/bin/docker-init.sh
USER vscode
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/docker-init.sh"]
CMD ["/bin/zsh"]
+40 -2
View File
@@ -91,7 +91,7 @@ Der Container mounted `~/.age` automatisch. Setze die Umgebungsvariable:
```bash
# Im Container-Terminal (SOPS_AGE_KEY_FILE ist bereits automatisch gesetzt!)
# Jetzt kannst du Secrets bearbeiten (wird transparent ver-/entschlüsselt):
sops apps/production/custom-configs/mas-secrets.sops.yaml
sops apps/production/custom-configs/mas-secret.yaml
```
### Schritt 3: VSCode Integration (optional)
@@ -130,7 +130,7 @@ kubectl get pods -n matrix
flux get helmreleases -A
# Secrets bearbeiten (mit verschlüsselung)
sops apps/production/custom-configs/mas-secrets.sops.yaml
sops apps/production/custom-configs/mas-secret.yaml
# FluxCD Sync erzwingen
flux reconcile kustomization production-apps --with-source
@@ -193,6 +193,44 @@ Siehe `README.md` → **Issue 3**. Kurz:
- `wellKnownDelegation: enabled: false` setzen
- Oder `.well-known/matrix/server` manuell auf `elementWeb` weiterleiten
## ⚠️ Wartungshinweis: Warum dieser Container regelmäßig getestet werden muss
Der Dockerfile installiert mehrere Tools über externe apt-Repos und Install-Skripte
(`pkgs.k8s.io`, `download.docker.com`, GitHub-Releases, `fluxcd.io`/`ohmyzsh.sh`
Installer). **Diese Quellen sind nicht unter unserer Kontrolle und können jederzeit
brechen** — genau das ist am 2026-07-28 passiert: der Container konnte seit
Fertigstellung nie erfolgreich gebaut werden, ohne dass es jemand bemerkt hat, weil
niemand ihn zwischenzeitlich tatsächlich gebaut hat. Gefundene und behobene Probleme:
| # | Problem | Ursache | Fix |
|---|---------|---------|-----|
| 1 | `apt.kubernetes.io` → `404 Not Found` | Google hat das alte Kubernetes-apt-Repo 2023 abgeschaltet | Umgestellt auf das offizielle Nachfolge-Repo `pkgs.k8s.io` (versioniert pro k8s-Minor-Version, aktuell `v1.34`) |
| 2 | `docker-ce-cli` "has no installation candidate" auf Apple Silicon | Repo-Zeile hatte `arch=amd64` hartkodiert, Build lief aber auf arm64 | `arch=$(dpkg --print-architecture)` zur Build-Zeit ermitteln |
| 3 | `useradd: group 'docker' does not exist` | Nur die Docker-**CLI** wird installiert (kein Daemon), daher legt kein Paket die `docker`-Gruppe automatisch an | `groupadd docker` explizit vor `useradd` |
| 4 | oh-my-zsh-Install schlägt mit Quoting-Fehler fehl | Verschachtelte `sh -c '...'`-Anführungszeichen in einer Zeile | Install-Skript erst in eine Datei laden, dann sauber mit `su - vscode -c "sh /tmp/install-omz.sh --unattended"` ausführen |
| 5 | `sops`-Binary war hart auf `linux.amd64` gepinnt | Lief auf Apple Silicon nur zufällig per QEMU-Emulation von Docker Desktop mit, nicht nativ | Arch dynamisch über `dpkg --print-architecture` auflösen (`linux.arm64` / `linux.amd64`) |
| 6 | `docker.sock`-Zugriff im Container: `permission denied` | Der gemountete Host-Socket gehört (je nach Docker-Setup) einer Gruppe/GID, die im Container nicht existiert oder nicht der `docker`-Gruppe entspricht (auf Docker Desktop für Mac/Windows z.B. GID 0/root statt einer eigenen `docker`-Gruppe) | `docker-init.sh`: Root-Entrypoint gleicht beim Container-Start die GID der `docker`-Gruppe an den tatsächlich gemounteten Socket an (bzw. tritt der GID-Inhaber-Gruppe bei, falls die GID schon vergeben ist), wechselt danach per `gosu` zu `vscode` |
**Konsequenz für die Zukunft:** Vor jeder größeren Änderung an `.devcontainer/` (oder
mindestens vierteljährlich) einmal real bauen und laufen lassen:
```bash
docker build -f .devcontainer/Dockerfile -t ess-gitops-devcontainer-test .devcontainer
docker run --rm \
-v ~/.kube:/home/vscode/.kube \
-v ~/.age:/home/vscode/.age \
-v /var/run/docker.sock:/var/run/docker.sock \
ess-gitops-devcontainer-test bash -c '
kubectl version --client && helm version --short && flux --version && \
sops --version && age --version && docker version --format "{{.Server.Version}}" && \
id vscode
'
```
Wenn `docker version` hier den echten Server, nicht nur die Client-Version zeigt, und
`id vscode` die passende Docker-Gruppe/GID auflistet, funktioniert der Socket-Zugriff
tatsächlich — nicht nur der Build.
## 📚 Weitere Ressourcen
- [Dev Containers Docs](https://containers.dev)
+8 -1
View File
@@ -50,8 +50,15 @@
"HACK",
"NOTE",
"XXX",
"DONE"
"DONE",
"[ ]",
"[x]"
],
"todo-tree.regex.regex": "(//|#|<!--|;|/\\*|^|^\\s*(-|\\d+.))\\s*($TAGS)",
"todo-tree.highlights.customHighlight": {
"[ ]": { "background": "#ff000080", "icon": "issue-opened" },
"[x]": { "background": "#00ff0080", "icon": "check" }
},
"todo-tree.tree.showScanModeButton": true,
"todo-tree.filtering.includeGlobs": [
"**/docs/TASKS.md",
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Runs as root at container start (before any `docker exec -u vscode` from VS Code).
# The docker.sock's GID is only known once the host socket is actually bind-mounted,
# so it can't be baked in at image build time - it must be reconciled here, at runtime.
set -e
if [ -S /var/run/docker.sock ]; then
SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
CURRENT_GID=$(getent group docker | cut -d: -f3)
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "$CURRENT_GID" ]; then
EXISTING_GROUP=$(getent group "$SOCK_GID" | cut -d: -f1)
if [ -n "$EXISTING_GROUP" ]; then
# GID is already taken by another group (e.g. GID 0/root - Docker Desktop for
# Mac/Windows owns the socket this way inside its VM), so join that group
# instead of trying to reassign it to 'docker'.
usermod -aG "$EXISTING_GROUP" vscode
else
groupmod -g "$SOCK_GID" docker
fi
fi
fi
exec gosu vscode "$@"
+1 -1
View File
@@ -26,7 +26,7 @@ echo ""
echo "📚 Useful commands:"
echo " - kubectl get pods -n matrix (check pod status)"
echo " - flux get helmreleases -A (check helm releases)"
echo " - sops apps/production/custom-configs/mas-secrets.sops.yaml (edit secrets)"
echo " - sops apps/production/custom-configs/mas-secret.yaml (edit secrets)"
echo ""
echo "🔗 For kubeconfig setup:"
echo " - Copy your ~/.kube/config to access the cluster"
-50
View File
@@ -1,50 +0,0 @@
name: Auto-Deploy on Push
on:
push:
branches:
- main
paths:
- 'apps/**'
- 'clusters/**'
- '.gitea/workflows/**'
jobs:
verify-and-notify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check YAML Syntax
run: |
echo "🔍 Validating YAML files..."
find apps clusters -name "*.yaml" -type f | while read file; do
if ! grep -q "^apiVersion:" "$file"; then
echo "⚠️ Warning: $file may not be a valid K8s manifest"
fi
done
echo "✅ YAML validation passed"
- name: Check for SOPS Encryption
run: |
echo "🔐 Checking SOPS status..."
for file in $(git diff --name-only origin/main...HEAD -- '**/secret*.yaml' '**/credentials*.yaml'); do
if grep -q "ENC\[" "$file"; then
echo "✅ $file is encrypted"
else
echo "⚠️ WARNING: $file may not be encrypted!"
fi
done
- name: Create Deployment Notification
run: |
echo "📤 Flux will reconcile changes within 1 minute"
echo "🔗 Monitor in Gitea: Projects → Releases (check tags)"
- name: List Changed Files
run: |
echo "📋 Files changed in this push:"
git diff --name-only origin/main...HEAD
-32
View File
@@ -1,32 +0,0 @@
name: Create Release on Milestone Tag
on:
push:
tags:
- 'm*-*-complete'
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Extract Milestone Info
id: milestone
run: |
TAG="${GITHUB_REF#refs/tags/}"
TITLE=$(git tag -l "$TAG" -n1 | awk '{print substr($0, index($0, $2))}')
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "title=$TITLE" >> $GITHUB_OUTPUT
echo "🏷️ Milestone: $TAG"
echo "📝 Title: $TITLE"
- name: Create Release
run: |
echo "📦 Creating release for milestone: ${{ steps.milestone.outputs.tag }}"
echo "${{ steps.milestone.outputs.title }}" > /tmp/release-notes.txt
echo "Created: $(date)" >> /tmp/release-notes.txt
cat /tmp/release-notes.txt
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
.claude/
+224
View File
@@ -0,0 +1,224 @@
# Leichter Verifikations-Job, portiert aus .gitea/workflows/deploy-on-push.yml
# (Gitea-CI-Rueckbau, CFGMON-11, erledigt 2026-08-01). Deployt nichts - Flux reconciled
# weiterhin selbststaendig aus dem Gitea-Mirror. Repo-Topologie: git.lab ist
# kanonisch, rohana/Gitea ist Push-Mirror und Flux-Quelle.
# Ohne workflow-Block legt GitLab auch dann eine Pipeline an, wenn KEIN Job auf sie
# passt - und fuehrt sie als "failed". Das ist rot ohne Fehler, und rot ohne Fehler
# gewoehnt einem das Hinsehen ab (management #0104). Real passiert am 2026-08-19,
# Pipeline 518. Diese Regeln verhindern die leere Pipeline, statt sie hinterher zu
# quittieren.
#
# Reihenfolge zaehlt: schedule zuerst, sonst faellt die taegliche Kanonisierung mit
# heraus - sie aendert keine Pfade und wuerde von der changes-Regel nie erfasst.
.pfade_mit_pipeline: &pfade_mit_pipeline
- apps/**/*
- clusters/**/*
- .gitlab-ci.yml
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "api"
- if: $CI_COMMIT_BRANCH == "main"
changes: *pfade_mit_pipeline
- when: never
verify:
image: alpine:3.20
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- apps/**/*
- clusters/**/*
- .gitlab-ci.yml
script:
- apk add --no-cache git >/dev/null
- |
echo "YAML-Manifest-Check..."
find apps clusters -name "*.yaml" -type f | while read f; do
grep -q "^apiVersion:" "$f" || echo "WARN: $f enthaelt kein apiVersion - evtl. kein K8s-Manifest"
done
- |
echo "SOPS-Check der in diesem Push geaenderten Secret-Dateien..."
for f in $(git diff --name-only HEAD~1..HEAD -- '**/secret*.yaml' '**/credentials*.yaml' 2>/dev/null || true); do
if grep -q "ENC\[" "$f"; then echo "OK: $f ist verschluesselt"; else echo "WARNUNG: $f ist moeglicherweise NICHT verschluesselt!"; fi
done
- echo "Flux reconciled die Aenderungen innerhalb ~1 Minute (Quelle Gitea-Mirror)."
# ---------------------------------------------------------------------------
# TURN-Rotation kanonisieren (laeuft NUR als Pipeline-Schedule)
#
# Der Rotations-CronJob laeuft im Cluster und erreicht git.lab nicht - er pusht
# seinen Branch deshalb nach Gitea. Von dort muss die Rotation ueber git.lab
# zurueck, sonst ueberschreibt der naechste Mirror-Push sie und Flux spielt still
# das ALTE Shared Secret wieder ein. Frueher war das ein monatlicher Handgriff;
# dieser Job erledigt ihn.
#
# Einrichtung:
# 1. OFFEN: Project Access Token, Rolle Maintainer, Scope write_repository
# -> CI/CD-Variable CANONIZE_TOKEN (masked + protected). Nur sorb legt das
# an; ohne das Token laeuft der Leerlauf gruen durch, aber die erste
# echte Rotation bricht ab. Siehe gitops#51.
# 2. ERLEDIGT: Schedule "TURN-Rotation kanonisieren (taeglich)", 17:05 Europe/
# Berlin, angelegt 2026-08-02. Probelaeufe: Pipeline 159 deckte die
# fehlende Lab-CA auf, 161 lief gruen durch ("Keine offene Rotation").
#
# Warum taeglich statt monatlich zum Rotationstermin: ein monatlicher Schedule
# hat genau einen Versuch. Faellt der Runner an dem Tag aus, faellt die
# Kanonisierung ein Monat lang aus. Taeglich holt der Job jede offene Rotation
# spaetestens am Folgetag nach und ist ansonsten in Sekunden fertig.
# ---------------------------------------------------------------------------
canonize_rotation:
image: alpine:3.20
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
variables:
GIT_DEPTH: "0" # volle Historie: wir brauchen merge-base
script:
- apk add --no-cache git ca-certificates >/dev/null
- |
# Lab-CA. Der Runner stellt sie nur seinem eigenen Klon-Schritt bereit, nicht
# dem Job-Script - ein 'git fetch origin' im Script scheiterte deshalb an
# "unable to get local issuer certificate" (live gesehen, Pipeline 159).
# Deshalb liegt die Kette hier im Repo; sie ist ein oeffentliches Zertifikat,
# kein Geheimnis. CI_SERVER_TLS_CA_FILE zuerst, falls der Runner sie doch liefert.
if [ -n "$CI_SERVER_TLS_CA_FILE" ] && [ -f "$CI_SERVER_TLS_CA_FILE" ]; then
export GIT_SSL_CAINFO="$CI_SERVER_TLS_CA_FILE"
else
export GIT_SSL_CAINFO="$CI_PROJECT_DIR/ci/lab-ca-chain.crt"
fi
echo "CA: $GIT_SSL_CAINFO"
test -f "$GIT_SSL_CAINFO" || { echo "ABBRUCH: CA-Datei fehlt."; exit 1; }
- git config --global user.email "ci@axion1337.chat"
- git config --global user.name "TURN-Rotation (automatische Kanonisierung)"
- git remote add gitea https://rohana.axion1337.de/sorb/axion1337.chat-gitops.git
# --prune ist Pflicht, nicht Kosmetik: der Runner recycelt seinen Workspace
# (GIT_STRATEGY=fetch), und ohne prune bleiben remote-tracking-Refs geloeschter
# Zweige stehen. Der Job saehe einen laengst entfernten Rotationszweig sonst
# dauerhaft weiter - live beobachtet am 2026-08-18, Pipeline 492: beide Remotes
# waren nachweislich leer, der Job meldete den Zweig trotzdem.
- git fetch --quiet --prune gitea
# Kein 'fetch origin' noetig: der Runner hat main schon ausgecheckt (GIT_DEPTH 0),
# CI_COMMIT_SHA ist der Stand von main beim Anlegen der Pipeline.
- git checkout -B main "$CI_COMMIT_SHA"
- |
# Zeitpunkt der Rotation aus dem SOPS-Metadatenblock lesen. 'lastmodified'
# ist Metadatum, kein Geheimnis - es steht im Klartext neben den ENC[]-Werten.
rotationszeit() {
git show "$1:apps/production/coturn-secret.yaml" 2>/dev/null \
| sed -n 's/^[[:space:]]*lastmodified:[[:space:]]*"\(.*\)".*/\1/p' | head -1
}
MERGED=0
UEBERHOLT=""
for ref in $(git for-each-ref --format='%(refname:short)' 'refs/remotes/gitea/turn-secret-rotation-*'); do
SHA=$(git rev-parse "$ref")
if git merge-base --is-ancestor "$SHA" HEAD; then
echo "bereits kanonisiert: $ref"
continue
fi
# Ueberholte Zweige NICHT mergen. Ein Zweig, dessen Rotation aelter ist als
# der Stand in main, bringt nichts Neues, konfliktiert aber mit allem, was
# seither passiert ist - inklusive coturn-secret.yaml und
# synapse-turn-secret.yaml. Eine unbedachte Hand-Aufloesung wuerde das
# TURN-Shared-Secret ZURUECKDREHEN; Synapse und coturn waeren dann uneins
# und TURN tot. Real passiert: der Zweig vom 2026-07-28 blieb nach dem Merge
# auf Gitea liegen und hat diesen Job neun Tage lang taeglich rot gefaerbt -
# womit die rote Pipeline als Alarm wertlos wurde (AGENTS.md: sie IST der
# einzige Meldeweg). Deshalb ist ein Ueberbleibsel hier Aufraeumarbeit und
# kein Vorfall: gemeldet ja, rot nein.
NEU=$(rotationszeit "$SHA"); IST=$(rotationszeit HEAD)
if [ -n "$NEU" ] && [ -n "$IST" ] \
&& [ "$(printf '%s\n%s\n' "$NEU" "$IST" | sort | head -1)" = "$NEU" ]; then
echo "ueberholt: $ref traegt die Rotation vom $NEU, main steht auf $IST."
UEBERHOLT="$UEBERHOLT ${ref#gitea/}"
continue
fi
echo "kanonisiere: $ref ($SHA)"
SUBJ="chore(coturn): TURN-Rotation aus ${ref#gitea/} uebernommen"
BODY="Automatisch kanonisiert: der Rotations-CronJob im Cluster erreicht git.lab nicht und pusht nach Gitea; dieser Commit bringt die Rotation auf den kanonischen Weg zurueck, bevor der Mirror sie ueberschreiben kann."
if ! git merge --no-ff -m "$SUBJ" -m "$BODY" "$SHA"; then
echo "MERGE-KONFLIKT in $ref - es wird nichts gepusht."
echo "⚠️ Beim Aufloesen NIEMALS die aeltere Fassung von coturn-secret.yaml oder"
echo " synapse-turn-secret.yaml uebernehmen: beide muessen dasselbe Shared"
echo " Secret tragen, sonst faellt TURN aus. Im Zweifel main behalten."
echo "Siehe CLAUDE.md (Abschnitt Repo Topology)."
exit 1
fi
MERGED=1
done
if [ -n "$UEBERHOLT" ]; then
echo
echo "AUFRAEUMEN: diese Zweige sind ueberholt und koennen auf Gitea geloescht werden:"
for b in $UEBERHOLT; do echo " - $b"; done
echo "(Der Job bleibt gruen - liegengebliebene Zweige sind kein Vorfall.)"
fi
echo "MERGED=$MERGED" > .canonize_state
- |
. ./.canonize_state
if [ "$MERGED" = "0" ]; then
echo "Keine offene Rotation - nichts zu tun."
exit 0
fi
# Token erst hier pruefen, nicht am Jobanfang: solange nichts zu pushen ist,
# soll der taegliche Lauf gruen durchgehen und die Gitea-Strecke verifizieren.
# Fehlt er, wenn es wirklich etwas zu tun gibt, ist Abbruch richtig.
if [ -z "$CANONIZE_TOKEN" ]; then
echo "ABBRUCH: Es gibt eine offene Rotation, aber CANONIZE_TOKEN ist nicht gesetzt."
echo "Siehe Kopf dieser Datei, Einrichtungsschritt 1."
exit 1
fi
# Sicherheitsnetz: die Rotation fasst genau diese Secrets an. Kaeme hier
# etwas Unverschluesseltes durch, waere das Shared Secret im Klartext in Git.
for f in apps/production/coturn-secret.yaml apps/production/synapse-turn-secret.yaml; do
if ! grep -q "ENC\[" "$f"; then
echo "ABBRUCH: $f ist nicht SOPS-verschluesselt - es wird nichts gepusht."
exit 1
fi
echo "OK: $f ist verschluesselt"
done
git push "https://oauth2:${CANONIZE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:main
echo "Gepusht. Der Mirror traegt es nach Gitea zurueck, der PR schliesst sich dort selbst;"
echo "der Rotations-Branch existiert dann nur noch auf Gitea und wird vom Mirror entfernt."
# ---------------------------------------------------------------------------
# Wiki-Inhalt kanonisieren (laeuft NUR als Pipeline-Schedule, ADR-0015)
#
# Wiki.js laeuft im Cluster und erreicht git.lab nicht - es pusht seinen Inhalt
# deshalb nach Gitea (sorb/ThreadNetWiki, oeffentlich lesbar). Dieser Job spiegelt
# den Stand von dort nach git.lab axion1337.chat/threadnet-wiki, damit der
# kanonische Speicher wieder im Lab liegt. Gleiche Umkehrung wie die TURN-Rotation.
#
# Einrichtung:
# 1. ERLEDIGT: Project Access Token (Rolle Maintainer, Scope write_repository) auf
# axion1337.chat/threadnet-wiki -> gitops-CI/CD-Variable WIKI_CANONIZE_TOKEN
# (masked + protected).
# 2. Schedule: laeuft im bestehenden Tages-Schedule mit (CI_PIPELINE_SOURCE ==
# "schedule"). threadnet-wiki wurde leer angelegt -> der erste Push legt main an,
# danach nur noch Fast-Forward (kein Force, Branch-Protection bleibt).
canonize_wiki:
image: alpine:3.20
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
variables:
GIT_DEPTH: "1" # nur fuer ci/lab-ca-chain.crt aus dem ausgecheckten Repo
script:
- apk add --no-cache git ca-certificates >/dev/null
- |
# Lab-CA fuer den git.lab-Push; der Gitea-Klon nutzt die System-CA (oeffentliches Zert).
if [ -n "$CI_SERVER_TLS_CA_FILE" ] && [ -f "$CI_SERVER_TLS_CA_FILE" ]; then
LABCA="$CI_SERVER_TLS_CA_FILE"
else
LABCA="$CI_PROJECT_DIR/ci/lab-ca-chain.crt"
fi
test -f "$LABCA" || { echo "ABBRUCH: Lab-CA-Datei fehlt."; exit 1; }
if [ -z "$WIKI_CANONIZE_TOKEN" ]; then
echo "ABBRUCH: WIKI_CANONIZE_TOKEN nicht gesetzt (siehe Kopf, Einrichtungsschritt 1)."
exit 1
fi
- git config --global user.email "ci@axion1337.chat"
- git config --global user.name "ThreadNet-Wiki Kanonisierung"
- git clone --bare --quiet https://rohana.axion1337.de/sorb/ThreadNetWiki.git /tmp/wiki.git
- GIT_SSL_CAINFO="$LABCA" git -C /tmp/wiki.git push "https://oauth2:${WIKI_CANONIZE_TOKEN}@${CI_SERVER_HOST}/axion1337.chat/threadnet-wiki.git" 'refs/heads/main:refs/heads/main'
- echo "git.lab threadnet-wiki spiegelt jetzt Gitea sorb/ThreadNetWiki (main)."
+12
View File
@@ -0,0 +1,12 @@
# Flux source-controller's default ignore rules strip image files (*.png, *.jpg,
# ...) from the build artifact. The platform-branding ConfigMap (configMapGenerator
# in apps/production/kustomization.yaml) needs these branding assets in the build,
# so re-include them. Negations are evaluated after the defaults.
!apps/production/branding/logo.png
!apps/production/branding/alpenglow.jpg
!apps/production/branding/favicon.ico
!apps/production/branding/favicons/favicon-32x32.png
!apps/production/branding/favicons/favicon-16x16.png
!apps/production/branding/favicons/android-chrome-192x192.png
!apps/production/branding/favicons/apple-touch-icon.png
!apps/production/branding/favicons/mstile-150x150.png
+467
View File
@@ -0,0 +1,467 @@
# AGENTS.md — axion1337.chat-gitops
> **Die Gruppenregeln sind kanonisch im `management`-Repo:**
> [`AGENTS.md`](https://git.lab/axion1337.chat/management/-/blob/main/AGENTS.md)
> — von außerhalb des Labs über den Gitea-Mirror lesbar:
> `https://rohana.axion1337.de/sorb/management`. Dort stehen Repo-Topologie und
> Mirror-Regeln, das Kanban-Framework (Status-Labels, WIP-Limit 2, ADR-Pflicht —
> auch für dauerhafte Regel-Ausnahmen), Deploy-Übergabe und AAR-Verfahren,
> Secrets-Handhabung und die Karpathy-Leitlinien. Sie gelten für **jede** Session
> in diesem Repo. Hier steht nur, was für dieses Repository zusätzlich gilt.
## Overview
This is a **GitOps-based Kubernetes deployment** of **Element Server Suite (ESS Community v26.4.0)**, a complete Matrix homeserver stack. The repository contains Infrastructure-as-Code using **FluxCD** for GitOps synchronization, with encryption (SOPS/age), service mesh (Traefik), certificate management (Cert-Manager), and auxiliary services like Authentik, TURN/coturn, Draupnir (moderation), ClamAV (content scanning), and Grafana monitoring.
**Key Stack Components:**
- **K3s**: Lightweight Kubernetes distribution running on Hetzner Cloud
- **FluxCD**: GitOps controller that watches this repository and auto-syncs changes
- **ESS (Matrix Umbrella Chart v26.4.0)**: Synapse, MAS (Matrix Authentication Service), ElementWeb, MatrixRTC
- **Authentik**: OIDC-based identity provider for centralized authentication, deployed both via HelmRelease and declarative Blueprints (`apps/authentik/authentik-blueprints.yaml`) for flows/OIDC-provider config that would otherwise only exist as manual admin-UI clicks
- **Traefik**: Ingress controller (built into K3s) for routing HTTP/HTTPS traffic
- **Cert-Manager**: Automatic TLS certificate provisioning from Let's Encrypt
- **SOPS + age**: Transparent encryption/decryption of secrets in Git
- **Monitoring**: Grafana Alloy (agent), Prometheus (metrics), Loki (logs)
- **coturn**: TURN/STUN server for WebRTC audio/video calls, with monthly automated shared-secret rotation via CronJob + PR workflow
- **Draupnir**: Matrix moderation bot (community successor to Mjolnir), ban lists/policy rooms
- **ClamAV**: Content scanning — a Synapse module for unencrypted-room uploads, plus a standalone `clamav-http-scanner` service that a patched Element Web client (ThreadNet-Web) calls both on send and on receive, extending coverage to encrypted rooms/DMs
- **NetworkPolicies**: default-deny-with-explicit-allow across `matrix` and `authentik` namespaces
- **`host-config/`**: the one part of this repo that is deliberately **not** managed by Flux/GitOps — see "Host-Level (non-GitOps) Changes" below
## Repo Topology (since 2026-07-31)
Canonical repo is **`git.lab/axion1337.chat/axion1337.chat-gitops`** (homelab GitLab,
resolvable only inside the lab) — all pushes go there; a push-mirror updates the Gitea
copy on `rohana.axion1337.de`, which remains the **Flux source** (the cluster pulls from
Gitea; the mirror delivers). **Never push directly to Gitea** for this repo — the mirror
force-overwrites divergent state. The same rule applies to ThreadNet-Web, threadnet-call,
thread-net-git, threadnet-operating and (since 2026-08-01) `management` (the former
`Backlogs` repo, renamed in the PM-framework restructuring — ADRs/vision/roadmap live
there). Since 2026-08-02 the wiki lives on git.lab too, so **no repo is authored on
Gitea any more**; the one process that still *writes* there is the TURN rotation
(see below).
**Why Gitea is the Flux source, and why that is not a leftover.** git.lab holds the
blueprints; Gitea holds a copy the cluster can reach without the lab being up. That
separation is deliberate: the Hetzner cluster must be buildable and re-deployable when
the homelab is offline, on holiday, or mid-rebuild — it therefore must not depend on a
host that only answers inside the lab. **Do not "fix" the Flux source to point at
git.lab**: it would look tidier and would couple production availability to the lab,
which is exactly what this split avoids.
**Issues live on git.lab** (migrated 2026-08-01, gitops#48): the old Gitea issues are
closed with a pointer to their GitLab counterpart. ⚠️ gitops issue numbers **shifted**
in the migration (Gitea had gaps from PRs; e.g. Gitea#48 → GitLab#46) — old references
"gitops#N" in commits/docs mean the **Gitea** number; the authoritative mapping is the
migration footer in each GitLab issue. Releases stay on Gitea (public download path).
**No exceptions left**: deploy-handover issues used to stay on the Gitea
`sorb/management` tracker because CFGMON had no route to git.lab. The site-to-site VPN
(ADR-0004) removed that reason, and on 2026-08-02 they moved (LABNET-03) — the Gitea
tracker is empty and the form now lives as a GitLab issue template in the management
repo. Issues live on git.lab, without exception.
**Wiki** (migrated 2026-08-02): the 15 pages now live in this project's GitLab wiki
(`…-gitops.wiki.git`, *Wiki* tab). ⚠️ The **`wiki` branch in this repo is NOT the wiki** —
it is a stale May snapshot of `docs/`; don't edit or trust it.
⚠️ **`wiki.lab` is gone** (2026-08-15). The Docusaurus site that aggregated the doc
sources was replaced by **Wiki.js inside this stack** (ADR-0014, `wiki.axion1337.chat`),
which holds the operations and user handbooks and is edited in its own UI rather than
aggregated read-only from source repos. `homelab/docs` is deliberately out of that
scope — sorb's homelab documentation is not part of the platform.
**The one write that still lands on Gitea**: the monthly TURN-rotation CronJob runs in
the cluster (no route to git.lab) and pushes its rotation branch to Gitea. Never merge
that PR on Gitea — the rotation has to travel back through git.lab, otherwise the next
mirror push overwrites it and Flux silently re-applies the *old* shared secret.
**This is automated — do not do it by hand.** The scheduled job `canonize_rotation` in
`.gitlab-ci.yml` runs daily on git.lab, picks up any `turn-secret-rotation-*` branch
from Gitea that is not yet in `main`, merges it, and pushes to git.lab; the mirror
carries it back and Gitea auto-closes the PR. Once merged, the branch exists only on
Gitea, so the next mirror run deletes it — no cleanup needed.
If the job fails (merge conflict, or the SOPS check finds an unencrypted secret file),
it fails **loudly and changes nothing** — the pipeline stays red until someone looks.
That red pipeline is the alarm; there is no separate reminder. Manual fallback, should
it ever be needed: fetch the branch, merge into main locally, push to git.lab (worked
example: 2026-08-01, commit `717638d`). If the mirror lags, force it via the GitLab API
`POST /projects/<id>/remote_mirrors/<mirror_id>/sync`.
## Repository Structure
```
gitops/
├── .gitlab-ci.yml # Lab-CI verify job (YAML validation + SOPS-encryption check)
├── clusters/matrix/ # Flux GitRepository definition; entry point for reconciliation
├── apps/
│ ├── base/
│ │ ├── infra/ # Core infrastructure (Cert-Manager, Namespaces, etc.)
│ │ └── matrix/ # HelmRepository definition for ESS OCI chart
│ ├── production/ # Main ESS deployment
│ │ ├── element-server-suite.yaml # HelmRelease (ESS chart v26.4.0)
│ │ ├── custom-configs/ # Overrides & custom configurations
│ │ │ ├── synapse-values.yaml # Synapse customizations (ConfigMap)
│ │ │ ├── element-values.yaml # ElementWeb customizations (ConfigMap)
│ │ │ └── mas-secret.yaml # MAS secrets (encrypted with SOPS)
│ │ ├── cert-issuer.yaml # Let's Encrypt ClusterIssuer
│ │ ├── apex-ingress.yaml # Apex-domain IngressRoutes (Element Web, /_scan, etc.)
│ │ ├── matrix-postgres-auth.yaml # PostgreSQL credentials
│ │ ├── coturn.yaml / coturn-secret.yaml / synapse-turn-secret.yaml
│ │ ├── turn-secret-rotation.yaml # Monthly CronJob, rotates coturn shared secret via PR
│ │ ├── draupnir.yaml / draupnir-pvc.yaml / draupnir-secret.yaml
│ │ ├── clamav.yaml / clamav-pvc.yaml / clamav_spam_checker.py # Synapse-side scan module
│ │ ├── clamav-http-scanner.py / -Dockerfile / .yaml # Client-side scan service
│ │ ├── synapse-backup.yaml / synapse-backup-secret.yaml
│ │ └── networkpolicy.yaml # Default-deny + explicit allow rules
│ ├── authentik/ # Identity Provider (separate namespace)
│ │ ├── authentik.yaml # HelmRelease
│ │ ├── authentik-blueprints.yaml # Flows/OIDC-provider as declarative code
│ │ ├── helm-repo.yaml # HelmRepository source
│ │ ├── ingress.yaml # Ingress route
│ │ ├── networkpolicy.yaml
│ │ └── authentik-secret.yaml # Secrets (admin password, OIDC client secret, etc.)
│ └── monitoring/ # Observability (Alloy, kube-state-metrics, node-exporter)
│ ├── alloy-config.yaml # Grafana Alloy configuration
│ └── kube-state-metrics.yaml # K8s metrics exporter
├── host-config/ # Host-level (non-GitOps) config, see below
│ └── maintenance-notify/ # systemd timer: pre-update mail/Matrix notifications (Issue #24)
├── .sops.yaml # SOPS encryption rules (age key definition)
├── scripts/
│ ├── install-hooks.sh # Installs git hooks for ConfigMap auto-tracking
│ └── hooks/ # Git hooks (pre-commit, post-commit, etc.)
└── docs/
├── README.md # Main deployment guide
├── TASKS.md # Task list & milestones (backlog itself lives in git.lab issues)
├── install.md # Installation instructions
├── ops-configmap-sync.md # ConfigMap syncing with git hooks
└── deployment-guides/ # Detailed guides for specific components (01-07)
```
## Host-Level (non-GitOps) Changes
Almost everything in this repo is reconciled by Flux. `host-config/` is the deliberate
exception: it holds scripts/systemd units meant to run **on the bare Hetzner host itself**
(not as a Kubernetes pod), for things Flux structurally can't reach — e.g. host package
management. There is no SOPS-on-host or Ansible-equivalent mechanism yet; deployment to the
host is manual (`scp`/SSH), and instance-specific values live in a config file on the host
(`/etc/<name>/config`), not hardcoded in the versioned script, so the pattern is reusable
across forks/other communities running this same stack. See
`docs/deployment-guides/07-host-maintenance-notifications.md` for the first (and so far only)
example of this pattern.
## Common Development Commands
### Flux / GitOps Synchronization
```bash
# Force immediate reconciliation (don't wait for 10-min auto-sync)
flux reconcile kustomization flux-system --with-source
flux reconcile kustomization production-apps --with-source
# Check reconciliation status
flux get kustomizations -A
flux get helmreleases -A
# View Flux logs
kubectl logs -n flux-system deployment/source-controller -f
kubectl logs -n flux-system deployment/helm-controller -f
```
### Kubernetes Cluster Status
```bash
# Check pod health in Matrix namespace
kubectl get pods -n matrix
kubectl get pods -n authentik
kubectl get pods -n monitoring
# Detailed pod inspection
kubectl describe pod <pod-name> -n matrix
kubectl logs <pod-name> -n matrix -f
# Check all services and ingresses
kubectl get svc -n matrix
kubectl get ingress -n matrix
```
### Certificate Management (Let's Encrypt / Cert-Manager)
```bash
# View certificate status
kubectl get certificate -n matrix
kubectl get certificaterequest -n matrix
kubectl get challenges -n matrix
# Debug failed certificate issuance
kubectl describe challenge <challenge-name> -n matrix
kubectl logs -n cert-manager deployment/cert-manager -f
# Inspect the issued certificate
kubectl get secret <cert-secret-name> -n matrix -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text -noout
```
### SOPS Secret Editing
SOPS transparently encrypts/decrypts secrets using the `age` key specified in `.sops.yaml`. The environment variable `SOPS_AGE_KEY_FILE` must point to your age private key.
```bash
# Edit an encrypted secret (decrypted for editing, re-encrypted on save)
sops apps/production/custom-configs/mas-secret.yaml
sops apps/authentik/authentik-secret.yaml
# Create a new secret file
sops -i --encrypted-regex '^(data|stringData)$' --input-type yaml --output-type yaml new-secret.yaml
# Decrypt to view
sops -d apps/production/custom-configs/mas-secret.yaml
```
Ensure `~/.age/keys.txt` exists and contains your age private key. See `.devcontainer/devcontainer.json` for setup details.
### Helm Chart Inspection
```bash
# List installed charts
helm list -n matrix
helm list -n authentik
# View rendered chart values
helm get values matrix-stack -n matrix
helm get manifest matrix-stack -n matrix | less
```
### Useful kubectl Shortcuts
```bash
# Port-forward to access services locally
kubectl port-forward -n matrix svc/synapse 8008:8008
# Execute command inside pod (for debugging)
kubectl exec -it <pod-name> -n matrix -- bash
# Stream logs from multiple pods
kubectl logs -n matrix -l app=synapse -f
# Bootstrap a service/bot account via MAS (no registration_shared_secret in this stack)
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage register-user <name> --yes
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage issue-compatibility-token <name>
```
## Architecture & Key Concepts
### FluxCD Reconciliation Flow
1. **Flux watches** `clusters/matrix/` for a FluxRepository resource pointing to this Git repo
2. **Kustomization stages** pull in configurations in order:
- `flux-system` (FluxCD itself)
- `infra-apps` (Namespaces, RBAC, Cert-Manager, HelmRepository sources)
- `production-apps` (Main ESS deployment and related services)
3. **HelmReleases** specify which charts to install and what values to use
4. **ConfigMaps/Secrets** provide values from files in the repo (e.g., custom Synapse config)
5. **Flux auto-reconciles** every 10 minutes, or immediately if Git changes are detected
### Element Server Suite (ESS) Chart Constraints
The ESS Helm chart (v26.4.0) has strict validation and specific quirks:
- **No `config:` blocks for core components** — use ConfigMap overrides instead
- **`serverName` must be at root level**, not nested under `synapse`
- **TLS in Ingress blocks is forbidden** — use `certManager: true` at root to auto-manage certificates
- **`camelCase` for component names**: `elementWeb`, `synapseAdmin`, `matrixAuthenticationService`, etc.
- **OCI HelmRepository only** — the chart is distributed via `oci://ghcr.io/element-hq/ess-helm`, not HTTP
- **Values must pass JSON schema validation** — invalid configs will cause reconciliation failures with cryptic schema errors
### NetworkPolicy Convention
Default-deny-with-explicit-allow across `matrix` and `authentik` namespaces
(`apps/production/networkpolicy.yaml`, `apps/authentik/networkpolicy.yaml`). Every new pod
needs its own explicit ingress-allow rule; NetworkPolicy matches on named **container ports**,
not Service ports — a frequent source of live incidents when a new component is added (wrong
port number/name silently blocks all traffic to it).
### Known Issues & Workarounds
**Issue: Let's Encrypt ACME Race Condition (Error 403 Order's status is processing)**
- Symptom: Certificate provisioning hangs when `elementWeb` and `wellKnownDelegation` are both enabled on the same domain
- Cause: Both request certificates for the same domain simultaneously; Let's Encrypt rejects concurrent requests
- Fix: Set `wellKnownDelegation: enabled: false` and serve `.well-known/matrix/server` via a separate Ingress route or static file
**Issue: HelmChart not ready / stat no such file or directory**
- Cause: Attempting to use a GitRepository source for the ESS chart (it has sub-charts that don't render correctly)
- Fix: Use the OCI HelmRepository source (`oci://ghcr.io/element-hq/ess-helm`) instead
**Issue: Certificate validation failures (No resources found)**
- Cause: Manual Kustomize patches conflict with the Helm chart's built-in certificate management
- Fix: Remove manual patches; rely on `certManager: true` at the root level of HelmRelease values
**Issue: Synapse module can't use asyncio**
- Cause: Synapse runs on Twisted's reactor, not a running asyncio event loop — `asyncio.open_connection`/`asyncio.wait_for` inside a Synapse module (e.g. `clamav_spam_checker.py`) fail immediately with `RuntimeError: no running event loop`, and can silently trigger a fail-open path instead of an obvious crash
- Fix: use `twisted.internet.reactor`/`HostnameEndpoint`/`connectProtocol` + a custom `Protocol` subclass; Twisted `Deferred`s are natively awaitable from `async def` inside Synapse. Standalone processes outside Synapse (e.g. `clamav-http-scanner.py`) don't have this constraint and can use plain sockets/asyncio.
### SOPS Encryption & Key Management
- `.sops.yaml` defines encryption rules (currently using `age` keys)
- Secrets matching the regex in `.sops.yaml` are automatically encrypted when committed
- The age private key (`~/.age/keys.txt`) must be available in your environment for decryption
- In the cluster, Flux decrypts secrets "on the fly" using a secret stored in `flux-system` namespace
To rotate SOPS keys:
```bash
# Regenerate and re-encrypt all secrets
sops updatekeys -y apps/
```
## Development Workflow
### Before Making Changes
1. **Understand dependencies** — check `kustomization.yaml` files to see the order of resource creation
2. **Verify chart schema** — review ESS chart documentation for constraints on the version being used
3. **Test locally if possible** — use `kubectl` port-forwards to verify connectivity before pushing changes
### Making Changes
1. **Edit ConfigMap files directly** — for non-secret customizations (Synapse config, Element Web themes, etc.)
- Changes are auto-tracked by git hooks installed via `./scripts/install-hooks.sh`
2. **Edit secrets with SOPS**`sops` transparently decrypts/re-encrypts on save
3. **Update HelmRelease values** — modify the `values` section in `element-server-suite.yaml` or reference ConfigMap sources
### After Committing
1. **Flux auto-detects changes** within ~1 minute (or manually trigger with `flux reconcile kustomization production-apps`)
2. **Monitor reconciliation** — watch pod logs and Flux status for errors
3. **Test functionality** — verify services are accessible and functioning as expected
### Git Hooks
After cloning, run:
```bash
./scripts/install-hooks.sh
```
This installs hooks that automatically commit ConfigMap changes to `.gitignore`-like tracking. See `docs/ops-configmap-sync.md` for details.
## Environment Setup
### Local Machine Prerequisites
- `kubectl` — cluster communication
- `flux` — GitOps CLI
- `helm` — chart inspection & debugging
- `sops` & `age` — secret management
- `git` — version control
- age key file at `~/.age/keys.txt` (request from team)
- kubeconfig at `~/.kube/config` (request from team)
### DevContainer (Recommended)
The `.devcontainer/` configuration provides a pre-configured environment:
```bash
# In VS Code: "Reopen in Container"
# Or manually:
docker build -t ess-devcontainer .devcontainer
docker run -it --rm \
-v ~/.kube:/home/vscode/.kube \
-v ~/.age:/home/vscode/.age \
-v ~/.ssh:/home/vscode/.ssh \
-v /var/run/docker.sock:/var/run/docker.sock \
ess-devcontainer
```
DevContainer includes:
- All required CLI tools (kubectl, flux, helm, sops, age, git, docker)
- VS Code extensions for YAML, Kubernetes, Helm
- Proper environment variables (`KUBECONFIG`, `SOPS_AGE_KEY_FILE`)
- Git hooks pre-installed
## Troubleshooting Checklist
- **Pod not starting?** → `kubectl describe pod <name> -n matrix` (check events)
- **Image pull failures?** → Check HelmRelease status: `kubectl get helmrelease -n matrix`
- **Secret not found?** → Verify SOPS decryption: `sops -d <secret.sops.yaml>` (must output valid YAML)
- **Certificate stuck?** → `kubectl describe certificate <name> -n matrix` (check for ACME errors)
- **Config validation error?** → Inspect HelmRelease status: `kubectl describe helmrelease <name> -n matrix` (JSON schema error message)
- **Cluster unreachable?** → Verify kubeconfig: `kubectl get nodes` (must connect to K3s)
- **NetworkPolicy blocking a new pod?** → Check it matches on container port name, not Service port
## Resources & References
- **README.md** — High-level overview and architecture
- **docs/TASKS.md** — Task backlog, milestones, and priority list (open backlog lives in [git.lab issues](https://git.lab/axion1337.chat/axion1337.chat-gitops/-/issues); the closed Gitea issues only point there)
- **docs/deployment-guides/** — Detailed setup guides for specific components (01-07)
- **docs/ops-configmap-sync.md** — Git hook configuration and auto-sync behavior
- **ESS Chart Docs** — `https://github.com/element-hq/ess-helm` (official Helm chart repository)
- **FluxCD Docs** — `https://fluxcd.io/docs/` (GitOps reconciliation & Kustomization)
- **Matrix Spec** — `https://spec.matrix.org/` (Matrix protocol specification)
---
name: karpathy-guidelines
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
license: MIT
---
# Karpathy Guidelines
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+1
View File
@@ -0,0 +1 @@
Read AGENTS.md — the canonical instruction file for this repository. All rules live there.
+75 -14
View File
@@ -4,18 +4,23 @@ Dieses Repository enthält die Infrastruktur-as-Code (IaC) für den Matrix-Homes
## 📑 Inhaltsverzeichnis
1. [Voraussetzungen & Lokale Tools](https://www.google.com/search?q=%231-voraussetzungen--lokale-tools)
2. [Architektur & Logik des Stacks](https://www.google.com/search?q=%232-architektur--logik-des-stacks)
3. [Aufbau des Repositories](https://www.google.com/search?q=%233-aufbau-des-repositories)
4. [Das Deployment (Aktueller Stand)](https://www.google.com/search?q=%234-das-deployment-aktueller-stand)
5. [Nützliche Befehle](https://www.google.com/search?q=%235-n%C3%BCtzliche-befehle)
6. [Troubleshooting & Known Issues](https://www.google.com/search?q=%236-troubleshooting--known-issues)
1. [Voraussetzungen & Lokale Tools](#1-voraussetzungen--lokale-tools)
2. [Architektur & Logik des Stacks](#2-architektur--logik-des-stacks)
3. [Aufbau des Repositories](#3-aufbau-des-repositories)
4. [Das Deployment (Aktueller Stand)](#4-das-deployment-aktueller-stand)
5. [Nützliche Befehle](#5-nützliche-befehle)
6. [Troubleshooting & Known Issues](#6-troubleshooting--known-issues)
7. [Weitere Ressourcen](#7-weitere-ressourcen)
-----
## 1\. Voraussetzungen & Lokale Tools
Um mit diesem Stack zu interagieren (Konfigurationen anzupassen, Secrets zu verschlüsseln, Fehler zu suchen), müssen folgende Tools lokal installiert sein:
**Empfohlen: `.devcontainer/` nutzen** ("Reopen in Container" in VS Code, oder `docker build`
+ `docker run` manuell, siehe [`.devcontainer/README.md`](.devcontainer/README.md)) - bringt
alle unten genannten Tools bereits fertig eingerichtet mit, ohne sie lokal zu installieren.
Alternativ, um mit diesem Stack zu interagieren (Konfigurationen anzupassen, Secrets zu verschlüsseln, Fehler zu suchen), müssen folgende Tools lokal installiert sein:
### 🛠️ Benötigte CLI-Tools
@@ -70,9 +75,10 @@ Das Setup basiert auf einer modernen, modularen GitOps-Architektur:
* **K3s**: Die leichtgewichtige Kubernetes-Distribution, die als Fundament dient.
* **FluxCD**: Der GitOps-Controller. Er überwacht dieses Git-Repository. Ändert sich hier eine Datei, wendet Flux die Änderung automatisch im Cluster an.
* **SOPS**: Erlaubt es, Passwörter (z.B. SMTP) verschlüsselt in Git zu speichern. Flux entschlüsselt diese "on the fly" im Cluster.
* **SOPS + age**: Erlaubt es, Secrets verschlüsselt in Git zu speichern. Flux entschlüsselt diese "on the fly" im Cluster. Mehrere Secrets nutzen zusätzlich einen zweiten, eng gescopten age-Key für automatisierte Rotation (siehe coturn TURN-Secret unten).
* **Traefik**: Der Ingress-Controller (Standard bei K3s). Er leitet Traffic von Port 80/443 an die richtigen internen Pods weiter.
* **Cert-Manager**: Spricht mit Let's Encrypt und stellt automatisch gültige TLS-Zertifikate für alle Ingress-Routen aus.
* **NetworkPolicies**: Default-Deny Ingress für die `matrix`- und `authentik`-Namespaces, mit expliziten Allow-Regeln pro Komponente (`apps/production/networkpolicy.yaml`, `apps/authentik/networkpolicy.yaml`).
### Matrix Stack (ESS Community v26.4.0)
@@ -80,10 +86,26 @@ Die Suite ist ein "Umbrella Chart", das aus mehreren Microservices besteht:
* **Synapse (`matrix.`):** Das eigentliche Backend (Homeserver) für die Chat-Nachrichten.
* **Matrix Authentication Service (MAS) (`account.`):** Der OIDC-basierte Login-Server. Zwingend erforderlich für moderne Matrix-Clients.
* **Element Web (`domain.tld`):** Der Web-Client für die Endnutzer.
* **Matrix RTC (`mrtc.`):** Die SFU (Selective Forwarding Unit) für Audio-/Video-Calls.
* **Element Web (`domain.tld`):** Eigener Fork (`sorb/threadnet-web`) des Web-Clients für die Endnutzer - Custom Themes, Element Desktop Setup-Seiten, Element-Call-Anpassungen.
* **Matrix RTC (`mrtc.`):** Die SFU (Selective Forwarding Unit) für Audio-/Video-Calls, mit eigenem Element-Call-Fork (`sorb/threadnet-call`) für höhere Video-Defaults (bis 1440p/60fps).
* **coturn:** TURN/STUN-Server für WebRTC hinter NAT (`hostNetwork: true`, außerhalb der NetworkPolicy-Kontrolle, stattdessen über die Hetzner Cloud Firewall abgesichert). Shared Secret wird monatlich automatisiert rotiert.
* **PostgreSQL:** Die relationale Datenbank für Synapse und MAS.
### Identity & Observability
* **Authentik** (`auth.`, `account.`): OIDC-Identity-Provider für Matrix-Enrollment, Passwort-Recovery und optionales 2FA/Passkey. Flows/Provider/Application deklarativ als Authentik-Blueprints erfasst (`apps/authentik/authentik-blueprints.yaml`), nicht nur in der UI geklickt.
* **Monitoring**: Grafana Alloy sammelt Metriken/Logs, Remote-Write zu einem externen Prometheus/Loki-Stack.
* **Backups**: Nächtliche, verschlüsselte & deduplizierte Borg-Backups (Postgres-Dumps + Synapse-`media_store`) zu einer Hetzner Storage Box, getrennt nach Namespace, mit eigenen Repos/Passphrasen.
### Moderation & Content Scanning
* **Draupnir**: Moderationsbot (Community-Nachfolger von Mjolnir) für Ban-Listen/Policy-Rooms.
* **ClamAV**: Zwei Bausteine für unterschiedliche Räume - ein eigenes Synapse-Modul (`clamav_spam_checker.py`) scannt Uploads in unverschlüsselten Räumen; ein zusätzlicher, eigenständiger `clamav-http-scanner`-Dienst wird vom gepatchten Element-Web-Client (`sorb/threadnet-web`) sowohl beim Senden als auch beim Empfangen aufgerufen und deckt damit auch verschlüsselte Räume/DMs ab. Details: `docs/deployment-guides/06-moderation-content-scanning.md`.
### Host-Level (nicht-GitOps) Änderungen
* `host-config/` ist bewusst der einzige Teil dieses Repos, den Flux **nicht** verwaltet - Skripte/systemd-Units, die direkt auf dem nackten Hetzner-Host laufen (z.B. `unattended-upgrades`-Vorab-Benachrichtigungen), für Dinge, die strukturell außerhalb der Reichweite von Flux liegen. Deployment erfolgt manuell per SSH, instanzspezifische Werte liegen in einer Config-Datei auf dem Host, nicht im versionierten Skript. Details: `docs/deployment-guides/07-host-maintenance-notifications.md`.
-----
## 3\. Aufbau des Repositories
@@ -106,9 +128,35 @@ gitops/
│ └── custom-configs/ # Eigene Anpassungen (Themes, Logging)
│ ├── synapse-values.yaml # Als ConfigMap
│ ├── element-values.yaml # Als ConfigMap
│ └── mas-secrets.sops.yaml # Als verschlüsseltes SOPS-Secret
│ └── mas-secret.yaml # Als verschlüsseltes SOPS-Secret
```
Weitere Secret-Dateien liegen direkt unter `apps/production/` bzw. `apps/authentik/`
(z.B. `coturn-secret.yaml`, `synapse-turn-secret.yaml`, `synapse-backup-secret.yaml`,
`authentik-backup-secret.yaml`) - jede einzeln SOPS-verschlüsselt, nicht in `custom-configs/`
gebündelt.
### Repo-Topologie (seit 2026-07-31)
**Kanonisch ist `git.lab/axion1337.chat/axion1337.chat-gitops`** (Homelab-GitLab, nur im
Lab auflösbar) — dort wird gepusht und läuft der CI-Verifikations-Job (`.gitlab-ci.yml`).
Die Kopie auf `rohana.axion1337.de` ist ein automatischer **Push-Mirror** und bleibt die
**Flux-Quelle**: der Cluster zieht unverändert von Gitea, der Mirror liefert. **Niemals
direkt nach rohana pushen** — der Mirror überschreibt divergente Stände.
**Issues und Wiki liegen seit 2026-08-01/02 ebenfalls auf git.lab**
([Issues](https://git.lab/axion1337.chat/axion1337.chat-gitops/-/issues) ·
[Wiki](https://git.lab/axion1337.chat/axion1337.chat-gitops/-/wikis/home) — der
Wiki-Reiter oben im Projekt). ⚠️ Die Issue-Nummern haben sich beim Umzug verschoben;
ein alter Verweis „gitops#N" meint die Gitea-Nummer, verbindlich ist der
Migrations-Fußtext im jeweiligen Issue. **Releases bleiben auf Gitea** (öffentlicher
Download-Pfad). Der `wiki`-Branch in diesem Repo ist ein **überholter Abzug von
`docs/` aus dem Mai** und nicht die gepflegte Fassung.
Alle Dokumentationsquellen zusammen (Plattform, Homelab, Arbeitsweise) gibt es unter
**[wiki.lab](https://wiki.lab)** — Konfiguration im Repo
[`homelab/wiki`](https://git.lab/homelab/wiki).
**Abhängigkeits-Logik:** Flux installiert erst `infra-apps` (damit Namespaces und Repositories existieren) und danach `production-apps` (das eigentliche ESS-Chart).
-----
@@ -156,7 +204,10 @@ spec:
name: ess-synapse-custom
valuesKey: values.yaml
- kind: Secret
name: ess-mas-custom-secrets
name: ess-mas-values-secret
valuesKey: values.yaml
- kind: Secret
name: synapse-turn-secret
valuesKey: values.yaml
values:
serverName: axion1337.chat
@@ -216,7 +267,7 @@ kubectl describe challenge <name> -n matrix
Um ein Passwort im GitOps-Repo zu ändern, editiert man die verschlüsselte Datei direkt via SOPS (sie wird transparent entschlüsselt und beim Speichern wieder verschlüsselt):
```bash
sops apps/production/custom-configs/mas-secrets.sops.yaml
sops apps/production/custom-configs/mas-secret.yaml
```
-----
@@ -244,4 +295,14 @@ sops apps/production/custom-configs/mas-secrets.sops.yaml
### Issue 4: Fehlende Zertifikate (`No resources found`)
* **Ursache:** Manuelle Kustomize-Patches kollidieren mit dem Helm-Chart.
* **Lösung:** Manuelle Patches löschen und das native Feature des Charts nutzen: `certManager: true` auf der obersten (Root-)Ebene der `values` setzen. Das Chart erstellt daraufhin die korrekten Ingress-Annotations und Secrets von selbst.
* **Lösung:** Manuelle Patches löschen und das native Feature des Charts nutzen: `certManager: true` auf der obersten (Root-)Ebene der `values` setzen. Das Chart erstellt daraufhin die korrekten Ingress-Annotations und Secrets von selbst.
-----
## 7\. Weitere Ressourcen
* **`CLAUDE.md`** (Repo-Root): Technische Referenz für KI-gestützte Arbeit an diesem Repo - Architektur, bekannte Chart-Quirks, Troubleshooting-Checkliste.
* **`docs/TASKS.md`**: Backlog-Pointer zu den [Gitea Issues](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues) - Details werden nicht mehr doppelt gepflegt.
* **[Gitea Releases](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/releases)**: Versionshistorie (SemVer, `vMAJOR.MINOR.PATCH` als Änderungsgrößen-Konvention, kein Kompatibilitätsvertrag - siehe [[00-TASKS]] Wiki für die Konvention).
* **[Wiki](https://git.lab/axion1337.chat/axion1337.chat-gitops/-/wikis/home)** (auf git.lab, Reiter *Wiki*): Ausführliche Historie, Incident-Notizen, Setup-Guides pro Komponente. Zusammen mit Homelab- und Verfahrensdoku auch unter **[wiki.lab](https://wiki.lab)**.
* **`docs/deployment-guides/`**: Detaillierte Guides für TURN-Server, Authentik, Monitoring, Element-Customization, Room-Policies, Moderation & Content-Scanning, Host-Wartungsbenachrichtigungen.
BIN
View File
Binary file not shown.
@@ -0,0 +1,23 @@
apiVersion: v1
kind: Secret
metadata:
name: authentik-backup-credentials
namespace: authentik
stringData:
borg-passphrase: ENC[AES256_GCM,data:5PApz4TqSNN2vVXeFSuomd051nl+cYk+a+STViwddG/Hj7XWQ099vvTlKSE=,iv:MFG04/66YqtOjZWsLpy236MYwR05z91ngOQ0BmLNxzA=,tag:gxD5prW8Ted3Q2ZY3sYSYQ==,type:str]
ssh-private-key: ENC[AES256_GCM,data:87PrkqAIH2xRCu3jlAK3Ts24XKRDoXNCWLomRhizCY43hwLeY5fNzfsGnMTKBt5IxL2bIljx+pbh/Y8IO38ISUUbUAczfi1KeN1gg69wKgqNAlckcmD0dkA2N2F0ZnEFX/rkad68YR8Hw1/A5h3qGUd5FwoVWzIFD/diY86s/LiQBYzwD4bYLUACHqIL7+QyJ6jEsFzA3tLwDySpUpM9OAjFQF0EazObChNYqe/qrc8i1gI8CptWOFkqiZ8Pen1E79gg/zgleOBqSmlcefgZk+JFPAfvORTHkDrHW9X/bLygaTQvskmGNzaKHpe2s/bVafWb7G60ROrpdjYIJfS7c0baRCSrd9kwdM4JQC2gSsMkI4v6oeFSvkTrb684B10zacZdiTKfySDV+Ry4hCZofF2RhAnsTbOpo0XANoOi4InoxUqGPfctVfUCVb6X+HIR5XYxpLrNwWqRpscMP2P3RJsaUZOinmQUKrHX/nMtPZ/ltpwjhQeAXZTtdyPxTCjHAh6fzouuHk66vlCdukXsMQDBdIfdA/3jjGbW,iv:yFNKkdegLLq8jq1Ya6v67urSJTdG3Ge4ZbmKizqQhmk=,tag:6l/qMb1d1oD8sfVEebW6gQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWMllTL2hRcEhCT2MxcWZy
dkxndmlZRW5FZ2NiWWdFckxqYTFKS1B6SXlvCjJySXRsZThvczNnWjlyM0N2Snha
eitvWUhhdmU4U0V3OG55WjdLbU9KcUEKLS0tIEM3MStzNUJhaGg4M0hKMVF1bnBB
RWVqVDRBNmJ0b1Bwd0l3dWxPT3Q0SU0KOEoyejkH4RC0p8ka3FjI7MyzRJg+uu7h
j3wf1q+Hgg73djDBSPYJkrB6Bdl4YMwo8SzbtW8O9elDE0qAioR0bQ==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-07-28T18:20:58Z"
mac: ENC[AES256_GCM,data:lwwNbxSxtgDTDmaWMU1uf7TBOEw8gFBFKb982VIsGMeM0fIPHvX12Qts90MNgYIJliOWgAWrwyvAgfXWKuE37RNo+BtyfHCWi4IESKSN/RJrd/yMpRKx+02rifH3nl26ZCAQT1Pa0fjI1SfMhbVzfnD9a/AMARXZMhLRc0OqczY=,iv:SgP2iMtENRtZfw6I9EaOsmvecFNYCIZWqVj+cZ+T7EI=,tag:s4oEyxqyUx6ibqsk4g9xpw==,type:str]
version: 3.13.3
+85
View File
@@ -0,0 +1,85 @@
# Nightly Borg backup of the authentik Postgres database to a Hetzner Storage Box
# (issues #6 + #15). See apps/authentik/authentik-backup-secret.yaml for the SSH key +
# Borg repo passphrase, and apps/production/synapse-backup.yaml for the matrix-side job
# (same Storage Box, separate repo/passphrase, offset schedule).
apiVersion: v1
kind: ConfigMap
metadata:
name: authentik-backup-known-hosts
namespace: authentik
data:
known_hosts: |
[u641795.your-storagebox.de]:23 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIICf9svRenC/PLKIL9nk6K/pxQgoiFC41wTNvoIncOxs
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: authentik-backup
namespace: authentik
spec:
schedule: "15 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
app.kubernetes.io/name: authentik-backup
app.kubernetes.io/component: backup
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: rohana.axion1337.de/sorb/axion-backup:v2
env:
- name: BORG_REPO
value: "ssh://u641795@u641795.your-storagebox.de:23/./authentik-backup"
- name: BORG_PASSPHRASE
valueFrom:
secretKeyRef:
name: authentik-backup-credentials
key: borg-passphrase
- name: SSH_PRIVATE_KEY_FILE
value: /secrets/ssh/ssh-private-key
- name: SSH_KNOWN_HOSTS_FILE
value: /secrets/known-hosts/known_hosts
- name: DB_HOSTS
value: "authentik:authentik-postgresql"
- name: PGUSER
value: authentik
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: pg-password
volumeMounts:
- name: ssh-key
mountPath: /secrets/ssh
readOnly: true
- name: known-hosts
mountPath: /secrets/known-hosts
readOnly: true
- name: scratch
mountPath: /scratch
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
volumes:
- name: ssh-key
secret:
secretName: authentik-backup-credentials
items:
- key: ssh-private-key
path: ssh-private-key
mode: 0400
- name: known-hosts
configMap:
name: authentik-backup-known-hosts
- name: scratch
emptyDir: {}
+291 -10
View File
@@ -28,12 +28,45 @@ data:
# from manual UI setup, likely a "select all" slip in the policy picker.
# These crash on an anonymous enrollment context ('AnonymousUser' object
# has no attribute 'group_attributes', etc). A prompt stage needs none here.
# Eindeutigkeit case-insensitiv pruefen (#0043). Authentiks eigene Pruefung
# ist case-sensitiv, "Boje" und "boje" gelten ihr also als verschieden --
# in Matrix kollidieren sie. Seit ADR-0011 faengt MAS das mit
# on_conflict:fail ab, aber erst BEIM LOGIN: der Nutzer registriert sich
# erfolgreich und scheitert spaeter ohne Erklaerung. Diese Policy gibt die
# Rueckmeldung dort, wo der Fehler entsteht.
#
# ⚠️ Bewusst ohne Zugriff auf request.user: die Stage laeuft im ANONYMEN
# Enrollment-Kontext. Genau daran sind die frueher hier haengenden
# System-Policies gescheitert ('AnonymousUser' hat kein group_attributes).
# Gelesen wird ausschliesslich prompt_data.
- model: authentik_policies_expression.expressionpolicy
state: present
identifiers:
name: matrix-username-eindeutig-ci
id: username_unique_ci
attrs:
execution_logging: false
expression: |
from authentik.core.models import User
gewuenscht = (request.context.get("prompt_data") or {}).get("username") or ""
gewuenscht = gewuenscht.strip()
if not gewuenscht:
return True # Pflichtfeld-Pruefung macht der Prompt selbst
if User.objects.filter(username__iexact=gewuenscht).exists():
ak_message("Dieser Benutzername ist bereits vergeben - auch in anderer "
"Gross-/Kleinschreibung. Bitte waehle einen anderen.")
return False
return True
- model: authentik_stages_prompt.promptstage
state: present
identifiers:
name: matrix-invitation-prompt
attrs:
validation_policies: []
validation_policies:
- !KeyOf username_unique_ci
# Correct stage chain, mirroring the working matrix-enrollment flow:
# Invite -> Prompt (username/email/password) -> Write -> Password -> Login
@@ -114,18 +147,41 @@ data:
# stages the built-in default-recovery-flow already uses successfully,
# plus our own redirect stage instead of falling back to the authentik
# dashboard (blocked for type=external Matrix users).
# ⚠️ Hier steckten ZWEI Fehler uebereinander (gitops#60).
#
# 1. !KeyOf koppelt jede Bindung daran, dass DIESER Eintrag im selben Lauf
# eine Model-Instanz erzeugt. Scheitert er, faellt der ganze Blueprint.
# Schlimmer: beim Protokollieren des Fehlers ruft Authentik str() auf das
# !KeyOf-Objekt, dessen __repr__ gegen ein LEERES Blueprint aufloest und
# dabei dieselbe Ausnahme wirft - die echte Fehlermeldung wird dadurch
# ueberdeckt. Man sieht nur noch "KeyOf: failed to find entry".
# Deshalb jetzt !Find gegen die Datenbank, wie in den Blueprints darunter
# ohnehin ueblich. Der Flow existiert, das ist stabiler und entkoppelt.
#
# 2. name und title MUESSEN gesetzt sein. Der FlowSerializer verlangt beide
# (keine Model-Defaults). Fehlen sie, scheitert die Validierung, der Eintrag
# bekommt keine Model-Instanz - und JEDES nachfolgende `!KeyOf
# matrix_recovery_flow` laeuft ins Leere. Genau das war der Zustand bis
# 2026-08-07: der Blueprint wurde bei jedem Lauf verworfen (gitops#60).
# Gegenprobe im selben ConfigMap: matrix-invitation-flow setzt beide und
# laeuft durch.
#
# Die Werte sind der Stand aus der Datenbank, damit sich an der angezeigten
# Seite nichts aendert. (Der Tippfehler "mail-adress" ist so gewachsen und
# bleibt bewusst stehen - Textaenderung waere eine eigene Entscheidung.)
- model: authentik_flows.flow
state: present
identifiers:
slug: matrix-recovery
id: matrix_recovery_flow
attrs:
name: "Welcome to aXion1337! Please provide a username or mail-adress."
title: "Welcome to aXion1337! Please provide a username or mail-adress."
designation: recovery
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 10
attrs:
stage: !Find [authentik_stages_identification.identificationstage, [name, default-recovery-identification]]
@@ -133,7 +189,7 @@ data:
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 20
attrs:
stage: !Find [authentik_stages_email.emailstage, [name, default-recovery-email]]
@@ -141,7 +197,7 @@ data:
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 30
attrs:
stage: !Find [authentik_stages_prompt.promptstage, [name, "Change your password"]]
@@ -149,7 +205,7 @@ data:
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 40
attrs:
stage: !Find [authentik_stages_user_write.userwritestage, [name, default-recovery-user-write]]
@@ -157,7 +213,7 @@ data:
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 100
attrs:
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-recovery-user-login]]
@@ -165,18 +221,38 @@ data:
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 110
attrs:
stage: !Find [authentik_stages_redirect.redirectstage, [name, matrix-invitation-redirect]]
# Wire the "Forgot password?" link on the real login flow used by MAS
# Wire the "Forgot password?" link on the real login flow used by MAS.
#
# ⚠️ Hier reicht recovery_flow allein NICHT. Der Serializer validiert das
# ganze Objekt, nicht nur die angegebenen Felder - ohne user_fields faellt
# er mit "When no user fields are selected, at least one source must be
# selected". Das war die eigentliche Ursache von gitops#60; sichtbar wurde
# sie erst, nachdem die !KeyOf-Verweise weg waren (die haben die Meldung
# ueberdeckt).
#
# Die uebrigen Felder stehen bewusst mit drin, obwohl sie den Model-Defaults
# entsprechen: Was der Serializer nicht bekommt, setzt er auf den Default
# zurueck. Ein Blueprint, der nur ein Feld nennt, kann so still andere
# Einstellungen kippen. Werte sind der Stand aus der Datenbank.
- model: authentik_stages_identification.identificationstage
state: present
identifiers:
name: default-authentication-identification
attrs:
recovery_flow: !KeyOf matrix_recovery_flow
recovery_flow: !Find [authentik_flows.flow, [slug, matrix-recovery]]
user_fields:
- email
- username
case_insensitive_matching: true
show_matched_user: true
pretend_user_exists: true
show_source_labels: false
enable_remember_me: false
matrix-mfa-setup-redirect.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
@@ -206,6 +282,63 @@ data:
order: 10
attrs:
stage: !Find [authentik_stages_redirect.redirectstage, [name, matrix-invitation-redirect]]
admin-mfa-enforcement.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: admin-mfa-enforcement
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# MFA-Pflicht fuer Admins, ohne sie fuer alle anderen zu erzwingen.
#
# Warum eine ZWEITE Validate-Stage statt not_configured_action am
# bestehenden default-authentication-mfa-validation umzustellen: dieses Feld
# haengt an der Stage, nicht an der Bindung - eine Umstellung wuerde alle
# Mitglieder treffen. Und die Standard-Stage anzufassen hiesse, ein Objekt
# aus Authentiks eigenem Blueprint zu veraendern.
#
# Diese Loesung fasst KEIN Authentik-Standardobjekt an. Sie haengt eine
# eigene Stage hinter die vorhandene und bindet sie an die Admin-Gruppe.
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
state: present
identifiers:
name: admin-mfa-validation
id: admin_mfa_stage
attrs:
# configure statt deny: Wer keinen Faktor hat, wird beim Login durch die
# Einrichtung GEFUEHRT. deny wuerde ihn aussperren - und bei einem
# Admin-Konto gibt es dann keinen Weg zurueck ausser ueber den Cluster.
not_configured_action: configure
configuration_stages:
- !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
- !Find [authentik_stages_authenticator_webauthn.authenticatorwebauthnstage, [name, default-authenticator-webauthn-setup]]
# Verhindert die doppelte Abfrage: Die Standard-Stage auf Ordnung 30
# validiert bereits, wer einen Faktor hat. Diese hier laeuft danach und
# ueberspringt sich, wenn das Geraet gerade eben benutzt wurde. Uebrig
# bleibt genau der Fall, um den es geht - Admin ohne zweiten Faktor.
last_auth_threshold: hours=1
# Ordnung 31: direkt hinter der Standard-MFA-Stage (30), vor dem Login (100).
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
order: 31
id: admin_mfa_binding
attrs:
stage: !KeyOf admin_mfa_stage
# Eine PolicyBinding mit gesetztem "group" prueft Gruppenmitgliedschaft
# (PolicyResult(group.is_member(user))). Ohne diese Bindung wuerde die
# Stage fuer ALLE gelten - dann waere 2FA fuer die ganze Instanz Pflicht.
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf admin_mfa_binding
order: 0
attrs:
group: !Find [authentik_core.group, [name, "authentik Admins"]]
matrix-brand-default-app.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
@@ -226,3 +359,151 @@ data:
domain: authentik-default
attrs:
default_application: !Find [authentik_core.application, [slug, matrix]]
# ThreadNet-Branding: Wer sich anmeldet, soll nicht auf einer Seite
# landen, die sich "authentik" nennt und ein fremdes Logo zeigt.
# Assets werden bewusst NICHT in Authentik hochgeladen, sondern von
# der Client-Auslieferung referenziert - sie liegen dort ohnehin und
# ein zweites Mal gepflegt zu werden ist genau die Quelle, aus der
# spaeter Abweichungen entstehen. Ein ConfigMap-Mount scheidet aus:
# die drei Dateien sind zusammen ~775 KB, base64-kodiert reissen sie
# das 1-MiB-Limit einer ConfigMap.
#
# Preis dieser Wahl: faellt Element Web aus, zeigt die Anmeldeseite
# Platzhalter statt Logo und Hintergrund. Kosmetisch, nicht
# funktional - anmelden kann man sich weiterhin.
#
# ⚠️ alpenglow.jpg existiert erst nach dem naechsten Client-Deploy.
# Wird diese Brand vorher ausgerollt, ist der Hintergrund 404 und
# Authentik zeigt Grau.
branding_title: ThreadNet
# Vorerst Authentiks eigenes Logo. Unser 512-px-PNG rendert in der
# Anmeldemaske in Naturgroesse und damit viel zu gross: Authentiks
# Default ist ein SVG, das sich seiner Box anpasst, ein PNG nicht.
#
# Explizit auf den Default gesetzt statt die Zeile zu loeschen - ein
# Blueprint mit state: present setzt weggelassene Felder NICHT zurueck,
# der alte Wert bliebe in der Datenbank stehen.
#
# ⚠️ SOLANGE DIESE ZEILE HIER STEHT, GEWINNT SIE. Wer das Logo in der
# Authentik-Oberflaeche auswaehlt oder hochlaedt, sieht es bis zur
# naechsten Blueprint-Reconciliation - danach steht wieder der Wert von
# hier. Ein neues Logo gehoert deshalb in diese Zeile, nicht in die UI.
#
# Was schon oeffentlich erreichbar ist und hier eingetragen werden kann:
# https://axion1337.chat/vector-icons/{24,120,144,152,180,512,1024}.png
# Alles quadratische Bildmarken. Fuer diesen Slot passt eher eine
# Wortmarke im Querformat - die gibt es noch nicht. Die vorhandene
# threadnet-logo-wortmarke.png liegt im wiki-Repo (Gruppe homelab) und
# ist von Hetzner aus NICHT erreichbar; sie muesste erst mit dem Client
# ausgeliefert werden, damit Authentik sie laden kann.
branding_logo: /static/dist/assets/icons/icon_left_brand.svg
branding_favicon: https://axion1337.chat/vector-icons/favicon.ico
branding_default_flow_background: https://axion1337.chat/themes/element/img/backgrounds/alpenglow.jpg
matrix-oidc-provider.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-oidc-provider
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# The OIDC Provider + Application linking Authentik to MAS was originally
# clicked together by hand in the UI and existed nowhere as code (issue
# #36): losing the Authentik DB would have meant re-creating this from
# scratch, including a new client_secret that MAS would then no longer
# match. client_secret is read from AUTHENTIK_MAS_OIDC_CLIENT_SECRET
# (see authentik.yaml HelmRelease values) rather than inlined here,
# since this ConfigMap itself is not SOPS-encrypted - the actual value
# lives in the authentik-credentials Secret instead.
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers:
name: Matrix Authentication Service
id: matrix_mas_provider
attrs:
client_type: confidential
client_id: dHbTAgAgXvjh3VALh220mB3dxcVXAifiXU2ZO3U6
client_secret: !Env AUTHENTIK_MAS_OIDC_CLIENT_SECRET
# Path includes MAS's own upstream-provider ID, not Authentik's -
# must match MAS's config exactly or the OIDC callback breaks.
redirect_uris:
- matching_mode: strict
url: https://account.axion1337.chat/upstream/callback/01KQDJTR1ZVTG8JQ220F5BNBFZ
# Stable across username renames - this is what keeps
# upstream_oauth_links rows valid after e.g. the elbojoloco rename.
sub_mode: hashed_user_id
include_claims_in_id_token: true
access_code_validity: minutes=1
access_token_validity: minutes=5
signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
property_mappings:
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-openid"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-email"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"]]
- model: authentik_core.application
state: present
identifiers:
slug: matrix
attrs:
name: aXion1337.chat Accountverwaltung
provider: !KeyOf matrix_mas_provider
meta_description: Matrixclient tailored for aXionCommunity
meta_publisher: aXionGaming
policy_engine_mode: any
open_in_new_tab: false
wiki-oidc-provider.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: wiki-oidc-provider
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers:
name: ThreadNet Wiki (Wiki.js)
id: wiki_oidc_provider
attrs:
client_type: confidential
# client_id + client_secret generiert Authentik selbst (kein Env-/SOPS-
# Aufwand; client_id ist ohnehin nicht geheim). Beide nach dem Apply in
# der Authentik-UI ablesen und in Wiki.js eintragen.
# redirect_uris: echte Wiki.js-Callback-URL (OIDC-Strategy-Key), 2026-08-12.
redirect_uris:
- matching_mode: strict
url: https://wiki.axion1337.chat/login/d3e7d0e4-adff-4421-b016-7758c44fd697/callback
sub_mode: hashed_user_id
include_claims_in_id_token: true
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
property_mappings:
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-openid"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-email"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"]]
# + Gruppen-Claim: entweder das mitgelieferte Groups-Scope-Mapping
# (falls vorhanden) oder ein eigenes Scope-Mapping, das "groups"
# zurückgibt — nötig für Rollen/Abschottung (#0049).
- model: authentik_core.application
state: present
identifiers:
slug: wiki-js
attrs:
name: ThreadNet Wiki
provider: !KeyOf wiki_oidc_provider
meta_description: Internes Wiki (Betrieb/Anwender), Zugriff nach Gruppe
policy_engine_mode: any
open_in_new_tab: false
# Rollen (#0049): Admin = bestehende Gruppe `authentik Admins` (liest+schreibt
# alles). `wiki-anwender` = kuratierte Leser von /anwender. Beide kommen über
# den profile->groups-Claim in Wiki.js an; Mitglieder pflegt sorb in Authentik.
- model: authentik_core.group
state: present
identifiers:
name: wiki-anwender
+5 -4
View File
@@ -7,10 +7,10 @@ stringData:
secret_key: ENC[AES256_GCM,data:yIyQapbFtFM11LynFtkV3ffExhaDfN9QHeFbI1T0xkIhgsV+9sjg3qwMVmeBlAe7xZl8gsAM4kDj2Q6O91OdDg==,iv:+Cl8vOcxG9/mgRheaCO0bLWyCJXN+f1F2DD3oeHbPFY=,tag:711ytyKf6/tmXomBLoffGA==,type:str]
pg-password: ENC[AES256_GCM,data:3w8R9mRjMXMJDLjrC8QYaXFHsCU3yYZs2PcaFQNp3Z4=,iv:G/aXgoGz3vBOzZ5K3Y+DDJsqer4F5gvcMmtkzRx93CU=,tag:dXPs1pY/APvnMlxdvB1EkA==,type:str]
smtp-password: ENC[AES256_GCM,data:JpMgaQFPkBzOg5WjvpmhM0kPwvZkH+4tQjT17RJHjG14WjmWtfG9Bg==,iv:zjQRLIlrxKv5hbd4JZowNUEiibiCUMf79acZY0+dYAc=,tag:ORPafTPyOQJvVvHWQGmqhA==,type:str]
mas-oidc-client-secret: ENC[AES256_GCM,data:0yx55FroLSxlnuYgfNwczu3PnbPm1kW74JtiU9oFevVqeQDZc385wU6x5X5TN7owXDO7QaOfGTTMvqIpbwQb6Q5Vt1VMToR+0f44oJcktYoTiDFU9Sy6lR/y6nlvBCNqeJg7vIyVpkIqxwqty15EekyqMpkIMp1fT6Pxmek0SO0=,iv:Ey06ljnqbVARDLVt2sLe8R776VEWpTlzI/+Nka5NocA=,tag:I+GNLHz4V8TFa2ijzK5y2Q==,type:str]
sops:
age:
- recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRekJuZythYzliTFJ3RlhS
R2p6TG9NeFdabFlPRWtpNHJMYVVxTWZEcmlRClk0WUorSzdxNlcyWHYwWFBTMnlq
@@ -18,7 +18,8 @@ sops:
QXVrY1NTeHZkeTlPRWNlVThzWno3T0kKC0KBoLT64GNqb8Ri9u69G7nqb1KftwwP
/24aVHrPxKi9d4ij9n3bvCYDF4rhtfexhrE4n7CfuKn2DcSiuTniuw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-29T21:43:59Z"
mac: ENC[AES256_GCM,data:Y+dJppkaVZ5NOhlvwbbsF5+vDFqGUI1Ps8IcE4J7FIW4HIdMVf6RKM0EInvPUW1LaBlmelCitcE30w0As7ysNRhLY8yUDaKUvuU6mRejlNUIF8wAHzhciL2jTvAQsArHjybJatEig28+wM9VcY8JEa/d/CmuiB9Nq4WbIV+JXlA=,iv:UQj2rIVLNPjtYp3d/jRyNfJyyyUsZ3+NDCgpI4aztzc=,tag:cwiCzG/A+rfRFfLjXVt82w==,type:str]
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-07-28T15:54:53Z"
mac: ENC[AES256_GCM,data:P6IF+jukwzldK92nHl6s4h6sS4ldXLwpyLpwv2tpI3vFWgTLEnGCnowi2k5lmWUlITEVmLLC0HvsBuduTiGI2sIHHt+r3RdqkV88HGn6oYDVq5a+Ax7ESfqti/4B7ClQCSxl/tU6hBUFe812DiBXJgA03UJQZn8uHY/dP/RgRpc=,iv:V8sqhbJcKglkKsQmJBdgoxDaCYJ3Wt/qRa18jEviH60=,tag:EiNotrYAKIzKndgjU/kTFQ==,type:str]
version: 3.12.2
Executable → Regular
+14
View File
@@ -40,6 +40,15 @@ spec:
global:
security:
allowInsecureImages: true
# Read by the matrix-oidc-provider blueprint via !Env, so the OAuth2
# Provider's client_secret can be captured as code without ever
# inlining the live credential into a plain (non-SOPS) ConfigMap.
env:
- name: AUTHENTIK_MAS_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: authentik-credentials
key: mas-oidc-client-secret
authentik:
log_level: info
@@ -89,3 +98,8 @@ spec:
memory: 256Mi
limits:
memory: 512Mi
# Chart's own generated policy allows ANY pod in ANY namespace on 5432
# (see issue #37) - disabled in favor of our own scoped policy in
# apps/authentik/networkpolicy.yaml.
networkPolicy:
enabled: false
+3
View File
@@ -9,3 +9,6 @@ resources:
- authentik.yaml
- ingress.yaml
- networkpolicy.yaml
# Backup zur Hetzner Storage Box (Issues #6 + #15)
- authentik-backup-secret.yaml
- authentik-backup.yaml
+86 -3
View File
@@ -4,9 +4,10 @@
# for upstream OIDC calls. Egress is intentionally untouched (federation-equivalent
# outbound calls like SMTP aren't restricted here).
#
# Note: authentik-postgresql already has its own NetworkPolicy from the Bitnami
# postgresql subchart (port 5432, no source restriction) - left alone, not duplicated,
# since it would get reset on the next Helm upgrade anyway.
# authentik-postgresql: the Bitnami postgresql subchart's own generated NetworkPolicy
# restricted the port (5432) but not the source - any pod in any namespace could reach
# it (issue #37). Disabled via postgresql.primary.networkPolicy.enabled: false in
# authentik.yaml and replaced below with a policy scoped to authentik-server/-worker.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
@@ -47,6 +48,35 @@ spec:
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-authentik-postgresql
namespace: authentik
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/component: primary
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: server
- podSelector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: worker
- podSelector:
matchLabels:
app.kubernetes.io/name: authentik-backup
ports:
- protocol: TCP
port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-acme-solver
namespace: authentik
@@ -64,3 +94,56 @@ spec:
ports:
- protocol: TCP
port: 8089
---
# Egress: alles bleibt erlaubt AUSSER dem Metadaten-Dienst (management #0088).
#
# Warum so schmal? Der Lehrbuch-Schnitt (0.0.0.0/0 except RFC1918) haette hier zwei
# Dinge zerrissen, beide ueber 10.0.0.3 (CFGMON im privaten Hetzner-Netz): Alloy
# schreibt Metriken und Logs dorthin, und der TURN-Rotations-CronJob erreicht Gitea
# ueber einen hostAlias auf dieselbe Adresse. Private Netze bleiben deshalb offen.
#
# 169.254.0.0/16 ist Link-Local; darin liegt bei Hetzner unter 169.254.169.254 der
# Metadaten-Dienst, aus jedem Pod unauthentifiziert abfragbar (nachgemessen 2026-08-19:
# HTTP 200 mit instance-id, hostname, region, MAC, Netzkonfiguration). userdata und
# public-keys waren LEER - es liegen hier also keine Zugangsdaten offen. Der Gewinn ist
# entsprechend bescheiden; der Schnitt kostet aber nichts und schliesst die Klasse.
#
# ACHTUNG beim Erweitern: Sobald eine Egress-Regel fuer einen Pod existiert, gilt fuer
# ihn Default-Deny fuer alles NICHT Aufgefuehrte. Die drei Bloecke unten sind daher
# Pflicht, nicht Bequemlichkeit - insbesondere DNS: fehlt es, steht alles, und der
# Fehler sieht wie ein Anwendungsproblem aus, nicht wie eine Firewall.
#
# Rollback: diese eine Policy im Namespace loeschen.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: egress-block-metadata
namespace: authentik
spec:
podSelector: {}
policyTypes:
- Egress
egress:
# 1. DNS - zuerst, weil ohne DNS nichts geht.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# 2. Cluster-intern: Pods und Services (inkl. API-Server 10.43.0.1).
- to:
- ipBlock:
cidr: 10.42.0.0/16
- ipBlock:
cidr: 10.43.0.0/16
# 3. Alles uebrige - Foederation, ACME, SMTP, Registries, privates Netz -
# ausser Link-Local.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.0.0/16
BIN
View File
Binary file not shown.
+5 -10
View File
@@ -89,20 +89,15 @@ data:
job_name = "flux"
}
// Scrape node-exporter DaemonSet
prometheus.scrape "node_exporter" {
targets = [{
__address__ = "prometheus-node-exporter.monitoring.svc.cluster.local:9100",
}]
forward_to = [prometheus.remote_write.selendis.receiver]
scrape_interval = "30s"
scrape_timeout = "10s"
}
// Kein node-exporter-Scrape mehr: Host-Metriken liefert der systemd-Exporter
// auf dem Host, den CFGMON direkt via 10.0.0.2:9100 scrapt. Das fruehere
// DaemonSet konnte wegen Portkonflikt mit ebendiesem Dienst nie starten
// (4883 Restarts) und haette dieselben Metriken doppelt geliefert (gitops#45).
// Scrape Synapse metrics
prometheus.scrape "synapse" {
targets = [{
__address__ = "matrix-stack-synapse-main.matrix.svc.cluster.local:9000",
__address__ = "matrix-stack-synapse-main.matrix.svc.cluster.local:9001",
}]
forward_to = [prometheus.remote_write.selendis.receiver]
scrape_interval = "30s"
+1 -1
View File
@@ -4,6 +4,6 @@ resources:
- namespace.yaml
- helm-repos.yaml
- kube-state-metrics.yaml
- node-exporter.yaml
- alloy-config.yaml
- alloy.yaml
- networkpolicy.yaml
+53
View File
@@ -0,0 +1,53 @@
---
# Egress: alles bleibt erlaubt AUSSER dem Metadaten-Dienst (management #0088).
#
# Warum so schmal? Der Lehrbuch-Schnitt (0.0.0.0/0 except RFC1918) haette hier zwei
# Dinge zerrissen, beide ueber 10.0.0.3 (CFGMON im privaten Hetzner-Netz): Alloy
# schreibt Metriken und Logs dorthin, und der TURN-Rotations-CronJob erreicht Gitea
# ueber einen hostAlias auf dieselbe Adresse. Private Netze bleiben deshalb offen.
#
# 169.254.0.0/16 ist Link-Local; darin liegt bei Hetzner unter 169.254.169.254 der
# Metadaten-Dienst, aus jedem Pod unauthentifiziert abfragbar (nachgemessen 2026-08-19:
# HTTP 200 mit instance-id, hostname, region, MAC, Netzkonfiguration). userdata und
# public-keys waren LEER - es liegen hier also keine Zugangsdaten offen. Der Gewinn ist
# entsprechend bescheiden; der Schnitt kostet aber nichts und schliesst die Klasse.
#
# ACHTUNG beim Erweitern: Sobald eine Egress-Regel fuer einen Pod existiert, gilt fuer
# ihn Default-Deny fuer alles NICHT Aufgefuehrte. Die drei Bloecke unten sind daher
# Pflicht, nicht Bequemlichkeit - insbesondere DNS: fehlt es, steht alles, und der
# Fehler sieht wie ein Anwendungsproblem aus, nicht wie eine Firewall.
#
# Rollback: diese eine Policy im Namespace loeschen.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: egress-block-metadata
namespace: monitoring
spec:
podSelector: {}
policyTypes:
- Egress
egress:
# 1. DNS - zuerst, weil ohne DNS nichts geht.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# 2. Cluster-intern: Pods und Services (inkl. API-Server 10.43.0.1).
- to:
- ipBlock:
cidr: 10.42.0.0/16
- ipBlock:
cidr: 10.43.0.0/16
# 3. Alles uebrige - Foederation, ACME, SMTP, Registries, privates Netz -
# ausser Link-Local.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.0.0/16
-29
View File
@@ -1,29 +0,0 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: prometheus-node-exporter
namespace: monitoring
spec:
interval: 1h
chart:
spec:
chart: prometheus-node-exporter
version: "4.x"
sourceRef:
kind: HelmRepository
name: prometheus-community
namespace: flux-system
values:
hostNetwork: true
hostPID: true
hostRootFsMount:
enabled: true
service:
port: 9100
targetPort: 9100
prometheus:
monitor:
enabled: false
tolerations:
- effect: NoSchedule
operator: Exists
+8
View File
@@ -35,6 +35,14 @@ spec:
services:
- name: element-web-docs
port: 80
# Client-seitiger ClamAV-Scan-Dienst (Issue #19-Erweiterung: Scanning auch für
# verschlüsselte Räume, direkt vom Browser aus aufgerufen)
- match: Host(`axion1337.chat`) && PathPrefix(`/_scan`)
kind: Rule
priority: 50
services:
- name: clamav-http-scanner
port: 8090
# Niedrigere Priorität: alles andere -> Element Web
- match: Host(`axion1337.chat`)
kind: Rule
Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,8 @@
FROM python:3.13-slim
COPY clamav-http-scanner.py /app/clamav-http-scanner.py
USER nobody
EXPOSE 8090
CMD ["python3", "/app/clamav-http-scanner.py"]
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# Standalone HTTP wrapper around ClamAV's clamd, reachable from browser JS (unlike clamd's raw
# TCP protocol). Used by the ThreadNet-Web client fork to scan files client-side, both before
# upload (plaintext, pre-encryption) and after download+decrypt of E2EE attachments - the two
# places Synapse's own check_media_file_for_spam module (Issue #19) can never see, since
# Synapse never holds the room's decryption key.
#
# Talks to clamd via its native INSTREAM protocol (docs.clamav.net/manual/Usage/ClamdProtocol.html):
# 1. send b"zINSTREAM\0"
# 2. send one or more chunks, each framed as a 4-byte big-endian length + that many bytes
# 3. send a zero-length chunk to signal end of stream
# 4. read the reply: "stream: OK" (clean) or "stream: <name> FOUND" (infected)
#
# Stdlib only, synchronous/threaded (ThreadingHTTPServer) - no asyncio/Twisted constraints
# here since this runs as its own plain process, unlike the Synapse module.
#
# Auth: requires "Authorization: Bearer <matrix access token>", validated against Synapse's
# own /_matrix/client/v3/account/whoami - reuses Synapse's existing auth rather than building
# a new one, and stops this becoming an open "test your malware against our AV" oracle for
# anyone on the internet. This is a hard failure (401) - unlike scan errors below, this is an
# abuse-prevention concern, not a reliability one.
#
# Fails open on clamd connection errors (treats the file as clean, logs loudly) - matches the
# same fail-open design as the Synapse module, so a ClamAV hiccup doesn't block all uploads/
# downloads site-wide.
import json
import logging
import os
import socket
import sys
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
logger = logging.getLogger("clamav-http-scanner")
CLAMD_HOST = os.environ["CLAMD_HOST"]
CLAMD_PORT = int(os.environ["CLAMD_PORT"])
SYNAPSE_WHOAMI_URL = os.environ["SYNAPSE_WHOAMI_URL"]
CLAMD_TIMEOUT_SECONDS = 30
MAX_BODY_BYTES = 100 * 1024 * 1024 # 100MB, matches typical Synapse upload size limits
def check_auth(authorization_header: "str | None") -> bool:
if not authorization_header or not authorization_header.startswith("Bearer "):
return False
token = authorization_header.removeprefix("Bearer ").strip()
request = urllib.request.Request(
SYNAPSE_WHOAMI_URL, headers={"Authorization": f"Bearer {token}"}
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status == 200
except urllib.error.URLError:
return False
def scan_bytes(data: bytes) -> "str | None":
"""Returns the detected signature name, or None if clean. Raises on connection errors."""
with socket.create_connection(
(CLAMD_HOST, CLAMD_PORT), timeout=CLAMD_TIMEOUT_SECONDS
) as sock:
sock.sendall(b"zINSTREAM\0")
chunk_size = 2**14
for offset in range(0, len(data), chunk_size):
chunk = data[offset : offset + chunk_size]
sock.sendall(len(chunk).to_bytes(4, "big") + chunk)
sock.sendall((0).to_bytes(4, "big"))
response = b""
while True:
part = sock.recv(4096)
if not part:
break
response += part
text = response.decode("utf-8", errors="replace").strip("\x00 \n")
if text.endswith("FOUND"):
return text.removeprefix("stream:").removesuffix("FOUND").strip()
return None
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None:
logger.info("%s - %s", self.address_string(), format % args)
def _send_json(self, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self) -> None:
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.end_headers()
def do_POST(self) -> None:
# matches the ingress PathPrefix(`/_scan`) route as-is (Traefik doesn't strip the
# prefix by default) - keep client-facing and internal path identical.
if self.path != "/_scan":
self._send_json(404, {"error": "not found"})
return
if not check_auth(self.headers.get("Authorization")):
self._send_json(401, {"error": "invalid or missing access token"})
return
length = int(self.headers.get("Content-Length", 0))
if length <= 0 or length > MAX_BODY_BYTES:
self._send_json(400, {"error": "missing or oversized body"})
return
data = self.rfile.read(length)
try:
signature = scan_bytes(data)
except OSError:
logger.exception(
"ClamAV scan failed (clamd at %s:%s unreachable?) - "
"treating file as clean (fail-open)",
CLAMD_HOST,
CLAMD_PORT,
)
self._send_json(200, {"clean": True, "scan_error": "scanner_unavailable"})
return
if signature is None:
self._send_json(200, {"clean": True})
else:
logger.warning("ClamAV flagged an upload/download: %s", signature)
self._send_json(200, {"clean": False, "signature": signature})
def main() -> None:
server = ThreadingHTTPServer(("0.0.0.0", 8090), Handler)
logger.info("Listening on :8090, clamd=%s:%s", CLAMD_HOST, CLAMD_PORT)
server.serve_forever()
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
apiVersion: v1
kind: Service
metadata:
name: clamav-http-scanner
namespace: matrix
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: clamav-http-scanner
ports:
- name: http
port: 8090
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: clamav-http-scanner
namespace: matrix
spec:
replicas: 1
strategy:
type: RollingUpdate
selector:
matchLabels:
app.kubernetes.io/name: clamav-http-scanner
template:
metadata:
labels:
app.kubernetes.io/name: clamav-http-scanner
spec:
containers:
- name: clamav-http-scanner
image: rohana.axion1337.de/sorb/clamav-http-scanner:v1.0.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8090
env:
- name: CLAMD_HOST
value: "clamav.matrix.svc.cluster.local"
- name: CLAMD_PORT
value: "3310"
- name: SYNAPSE_WHOAMI_URL
value: "http://matrix-stack-synapse.matrix.svc.cluster.local:8008/_matrix/client/v3/account/whoami"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 5
periodSeconds: 10
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: clamav-data
namespace: matrix
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 3Gi
+66
View File
@@ -0,0 +1,66 @@
apiVersion: v1
kind: Service
metadata:
name: clamav
namespace: matrix
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: clamav
ports:
- name: clamd
port: 3310
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: clamav
namespace: matrix
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: clamav
template:
metadata:
labels:
app.kubernetes.io/name: clamav
spec:
containers:
- name: clamav
image: clamav/clamav:1.5.3
imagePullPolicy: IfNotPresent
ports:
- name: clamd
containerPort: 3310
volumeMounts:
- name: data
mountPath: /var/lib/clamav
resources:
requests:
cpu: 100m
memory: 1.5Gi
limits:
memory: 3Gi
# clamd needs the full signature DB downloaded (freshclam, can take several
# minutes on first start) before it accepts connections - the image's own
# healthcheck script accounts for this via a long StartPeriod.
livenessProbe:
exec:
command: ["clamdcheck.sh"]
initialDelaySeconds: 60
periodSeconds: 30
failureThreshold: 10
readinessProbe:
exec:
command: ["clamdcheck.sh"]
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 20
volumes:
- name: data
persistentVolumeClaim:
claimName: clamav-data
+123
View File
@@ -0,0 +1,123 @@
# Synapse spam-checker module (Issue #19): scans locally-stored and remote/federated media
# through ClamAV's clamd daemon via its native INSTREAM protocol, before Synapse serves it.
#
# Talks to clamd using Twisted's networking primitives - NOT asyncio's open_connection/
# wait_for. Synapse runs on Twisted's reactor, which does not have a running asyncio event
# loop underneath it, so raw asyncio socket calls fail immediately with
# "RuntimeError: no running event loop" (confirmed live, 2026-07-29 - see git history for
# the asyncio-based version that failed this way). Twisted Deferreds are natively awaitable
# from an `async def` when Synapse wraps the callback via Deferred.fromCoroutine(), so this
# stays plain async/await from the caller's perspective.
#
# clamd INSTREAM protocol (docs.clamav.net/manual/Usage/ClamdProtocol.html):
# 1. send b"zINSTREAM\0"
# 2. send one or more chunks, each framed as a 4-byte big-endian length + that many
# data bytes
# 3. send a zero-length chunk (b"\x00\x00\x00\x00") to signal end of stream
# 4. read the reply: "stream: OK\0" (clean) or "stream: <name> FOUND\0" (infected)
#
# Fails open (allows the file through) on any connection/timeout error against clamd,
# so a scanner outage can't take down media uploads for the whole homeserver - logged
# loudly so an outage is still visible in the logs.
import logging
from typing import Any, Union
from twisted.internet import reactor
from twisted.internet.defer import Deferred, TimeoutError as TwistedTimeoutError
from twisted.internet.endpoints import HostnameEndpoint, connectProtocol
from twisted.internet.protocol import Protocol
from synapse.module_api import ModuleApi, NOT_SPAM
from synapse.module_api.errors import Codes
logger = logging.getLogger(__name__)
CHUNK_SIZE = 2**14 # matches ReadableFileWrapper.CHUNK_SIZE
CLAMD_TIMEOUT_SECONDS = 30
class _ClamdInstreamProtocol(Protocol):
"""Speaks clamd's INSTREAM protocol for a single scan, then closes."""
def __init__(self, data: bytes, result: "Deferred[bytes]"):
self._data = data
self._result = result
self._buffer = bytearray()
def connectionMade(self) -> None:
self.transport.write(b"zINSTREAM\0")
for offset in range(0, len(self._data), CHUNK_SIZE):
chunk = self._data[offset : offset + CHUNK_SIZE]
self.transport.write(len(chunk).to_bytes(4, "big") + chunk)
self.transport.write((0).to_bytes(4, "big"))
def dataReceived(self, data: bytes) -> None:
self._buffer.extend(data)
if self._buffer.endswith(b"\0") or self._buffer.endswith(b"\n"):
self.transport.loseConnection()
def connectionLost(self, reason: Any = None) -> None:
if not self._result.called:
self._result.callback(bytes(self._buffer))
class ClamAVSpamChecker:
def __init__(self, config: dict, api: ModuleApi):
self.api = api
self.clamd_host = config["clamd_host"]
self.clamd_port = config["clamd_port"]
self.api.register_spam_checker_callbacks(
check_media_file_for_spam=self.check_media_file_for_spam,
)
@staticmethod
def parse_config(config: dict) -> dict:
if "clamd_host" not in config or "clamd_port" not in config:
raise ValueError(
"clamav_spam_checker config requires 'clamd_host' and 'clamd_port'"
)
return config
async def check_media_file_for_spam(
self, file_wrapper: Any, file_info: Any
) -> Union[Any, Codes, bool]:
buffer = bytearray()
await file_wrapper.write_chunks_to(buffer.extend)
try:
verdict = await self._scan(bytes(buffer))
except Exception:
logger.exception(
"ClamAV scan failed (clamd at %s:%s unreachable?) - "
"allowing file through (fail-open)",
self.clamd_host,
self.clamd_port,
)
return NOT_SPAM
if verdict is None:
return NOT_SPAM
logger.warning("ClamAV rejected an upload: %s", verdict)
return Codes.FORBIDDEN
async def _scan(self, data: bytes) -> "str | None":
"""Returns the detected signature name, or None if the file is clean."""
result: "Deferred[bytes]" = Deferred()
endpoint = HostnameEndpoint(reactor, self.clamd_host.encode(), self.clamd_port)
await connectProtocol(endpoint, _ClamdInstreamProtocol(data, result))
result.addTimeout(CLAMD_TIMEOUT_SECONDS, reactor)
try:
response = await result
except TwistedTimeoutError:
raise TimeoutError(
f"clamd at {self.clamd_host}:{self.clamd_port} did not respond in time"
)
text = response.decode("utf-8", errors="replace").strip("\x00 \n")
# "stream: OK" or "stream: <signature name> FOUND"
if text.endswith("FOUND"):
return text.removeprefix("stream:").removesuffix("FOUND").strip()
return None
+313
View File
@@ -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()
+85
View File
@@ -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: {}
+19 -10
View File
@@ -4,19 +4,28 @@ metadata:
name: coturn-secret
namespace: matrix
stringData:
TURN_SECRET: ENC[AES256_GCM,data:SILIqMB+fmAMFITAL7lG1hOgICec6BJf1mOcK0gdmnCHWYqRuJv7jgjfGylG25xzQKi+zE7Qual9PnkZG2KiOA==,iv:+GZqLGusE4Q0x2jEEtFxj06rryyQmQhXdkTy4eE8ZHw=,tag:OpSZkinPTAi1ZKWyo8OX3A==,type:str]
TURN_SECRET: ENC[AES256_GCM,data:Cbu5SoxQp0L9WFZFbEiyK8j0IJuSgoJE9OpRKZVXyF+PcTM+ewD+3TI8xq+g0C34XDWeaJdxaj3B4yyKsqQLIA==,iv:lW8lD3D75Z0b0EfBj5myUrv4GWMvRTBELjhCVm+QJjc=,tag:nC5L/PcYPoLJP1whSSfSTA==,type:str]
sops:
age:
- recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyRk1mK3NWc1l4T0JCOFpF
S0RuQ3ViZmo3QTNVL2JvZ0hzMy91R2l0TEhzCk01a1VGdk1sdVg4aWswTzRibXI4
ZlJtNFF5MjBONEZOaWVpeU5taHl2bkEKLS0tIGxpUHY3NUFLWFBaWm1QSlZiVFkx
MEJleHFnd3oyT3VPL2dsYkpMUlRkOWMKcKUIgsQ/ff49pGGXMnYwJmwqPVC7woAR
IEzvhcNX97xx746SnrxZe5t2YadsYMkYIl0nvqBPJhSlvqMNafpQbQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQMncxOEhRZC9jcHpjT3dW
Q1RNaW5pc00zVnJhWHRnZHd3TGhURWNZU2dJCk85bXJkbTEyd1ZybjhDT05pL2c3
ZU9EUSt1eDlSQWJyVGtsak1oS3FSR2sKLS0tIEFUdXVHL0V1ZW5VMVVBZEJaYUIw
U1BrYlJyQVZkZFhBdmdwbDMyK3lTQkkKEaSy1o+IICf2uaT6olapRJa/duXxjOBg
OqRS9axnJ71XxEnHjLTsCbkI5b+8Fux08qKaH9sMsJrWOiSHDdTXXQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-29T21:06:21Z"
mac: ENC[AES256_GCM,data:UhyR5m1HYWrZHwNLW5sg2PxbpaydWbP5cekghGlzSpQg7CYEcvZw3tJ/qB8zA19xZSM7tdSHOXdD+QytRq6qW59M1unqMaumA43B6JxQg1C1NdXAW0mkSc2WiNchvgpVii9P/TVlzSSIRwC3YGCQUsfa3SSfNzI4Z6fMuBnhYLE=,iv:4HYxbrYSRJLe1KcQ6q8bpee8/Lx1m3pPmisb/L2Mu64=,tag:l7n3u+Pg6533OzwtNUZvNw==,type:str]
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwQk82eGdWRmdRNFJQR0xF
SWhNRUtNWThZNFM0aXU3V0N4UkhtTy9NTFdnCi9pa3dsVXRja1dTL1pZTnoxQ0JT
UUIxekZnVGUvdFgyblFiS0JLMjU3L2cKLS0tIE1IRTJ0M3kvMFZPWVVDYjJlVkk0
eGJQOTVUc1NsNE5GdmJtODlmdHp3c2cKHTP6YRMTdYE/iBuSZs/Tjt4TwKCxHEIu
f3jTblKIqWwRHKCgOIkC16QDbpMBlNLH3JknJEdIjkB2HIrXrw1MNA==
-----END AGE ENCRYPTED FILE-----
recipient: age1x4jjwc8nuttwr8us924pvdc6dll5npkc6c8f4zf2hx5d2qu75dtqx0fm0d
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-08-01T02:00:01Z"
mac: ENC[AES256_GCM,data:1Tx2/4O/fcv9BPYLXainmsILC2HbBJtJkgVnbkCe9oVSKca/hVf6zAa8Pd6n/tvHHAuU1Ghm2mglk/IxbNKgCDHH+xaEeK5sfhAK0Ot2ffIMLK5chBNMH0DpvTjqP8ttMidgPJ4XYzizB850gq7pSRrZBJCC2oG66F+WPnfKkxQ=,iv:NIDfNbIyWSgvjpXtQFVjnZFjtKx8sV3a45ssiebkHf0=,tag:hNDmHPTu4RyNMvjyfJRCmA==,type:str]
version: 3.12.2
Executable → Regular
+24 -2
View File
@@ -65,6 +65,17 @@ metadata:
namespace: matrix
spec:
replicas: 1
# hostNetwork pods bind directly to the node's ports (3478/5349) - on this single-node
# cluster, RollingUpdate's default "bring up the new pod before removing the old one"
# can never schedule (port conflict). Recreate kills the old pod first.
# Note: switching to Recreate on an existing Deployment that already had the
# RollingUpdate defaults recorded required a one-time manual
# `kubectl patch --type=merge -p '{"spec":{"strategy":{"rollingUpdate":null,"type":"Recreate"}}}'`
# (2026-07-28) - a YAML `rollingUpdate: null` in this file is dropped before reaching the
# API server (client-side omits null keys) rather than sent as an explicit field deletion,
# so it can't clear an already-set field on its own.
strategy:
type: Recreate
selector:
matchLabels:
app: coturn
@@ -74,12 +85,18 @@ spec:
app: coturn
annotations:
prometheus.io/scrape: "false"
# Bumped on every TURN_SECRET rotation (Issue #38) to force a new pod, since
# Kubernetes doesn't restart running pods when a referenced Secret's content
# changes and the initContainer that reads it only runs once at pod start.
rotated-at: "2026-08-01T02:00:01Z"
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
initContainers:
- name: init-config
image: busybox:1.28
# Gepinnt und aktuell gehalten (#0052); 1.36 ist die im Repo bereits
# anderswo genutzte Version — ein Stand statt zwei.
image: busybox:1.36
command:
- sh
- -c
@@ -104,7 +121,12 @@ spec:
readOnly: true
containers:
- name: coturn
image: coturn/coturn:latest
# Gepinnt statt ':latest' (#0052). Vorher lief hier 4.10.0, waehrend ':latest'
# laengst auf 4.17.2 zeigte: mit imagePullPolicy IfNotPresent haelt der Node das
# einmal gezogene Image fest, und der naechste Pod-Neustart auf einem frischen
# Node waere still ueber sieben Minor-Versionen gesprungen. Genau deshalb ist
# ':latest' weder reproduzierbar noch sinnvoll scanbar.
image: coturn/coturn:4.17.2
imagePullPolicy: IfNotPresent
ports:
- name: stun-udp
@@ -1,3 +1,8 @@
# Themes: die Paletten stammen aus Anthropics theme-factory-Skill und sind dort
# woertlich uebernommen — nicht frei erfinden. Herkunft, Werte und Fallen
# (u. a.: is_dark steht nicht verlaesslich in den Beschreibungen) stehen in
# shared/branding.md im management-Repo. Zweite Kopie derselben Themes liegt in
# ThreadNet-Web unter apps/desktop/axion1337/config.json — beim Aendern mitziehen.
apiVersion: v1
kind: ConfigMap
metadata:
@@ -10,16 +15,33 @@ data:
config.json: |
{
"brand": "aXion1337.Chat",
"_kommentar_custom_urls": "Entscheidung sorb 2026-08-19 (management #0099). Blendet den 'Bearbeiten'-Knopf am Servernamen aus (ServerPicker.tsx) - der Homeserver ist damit ueber die Oberflaeche nicht mehr wechselbar, und die Login-Fehlermeldung benennt bei 401/403 den Server statt generisch zu bleiben (ErrorUtils.tsx). Es ist eine OBERFLAECHEN-Sperre: der hs_url-Query-Parameter wird in MatrixChat.tsx (mobile Registrierung, Registrierungs-Bestaetigung) weiterhin ohne diese Pruefung uebernommen. Gegen das offene Advisory GHSA-wrcp-5v3v-3j6v (betrifft < 1.12.22, wir laufen 1.12.17) verengt das die Flaeche, ersetzt aber NICHT das Upstream-Update aus #0099. Dieselbe Zeile muss im Desktop-Client stehen - der hat seine eigene config.json.",
"disable_custom_urls": true,
"_kommentar_bug_report": "local = der Knopf erscheint als 'Protokolle herunterladen'; die Logs bleiben auf dem Geraet und gehen an KEINEN Server. Ohne diesen Wert ist die ganze Sektion unsichtbar und Nutzer kommen gar nicht an ihre Logs (ThreadNet-Web#9). Der element.io-Endpunkt wurde am 2026-08-01 bewusst entfernt - dieser Wert holt die Faehigkeit zurueck, ohne den Abfluss.",
"bug_report_endpoint_url": "local",
"_kommentar_report_event": "Weg B (Entscheidung sorb 2026-08-15): Meldungen bleiben im event_reports-Speicher des Servers und werden ueber Element Admin gesichtet - Draupnir bekommt dafuer BEWUSST keine Server-Admin-Rechte. Ohne diesen Text sieht der Melder nach dem Absenden gar nichts und weiss nicht, ob und an wen die Meldung geht (#0045).",
"report_event": {
"admin_message_md": "Deine Meldung ist bei der Serveradministration eingegangen und wird gesichtet.\n\nFuer Rueckfragen oder wenn es dringend ist, schreib bitte direkt an [@sorb:axion1337.chat](https://matrix.to/#/@sorb:axion1337.chat)."
},
"branding": {
"auth_header_logo_url": "vector-icons/512.png",
"logo_link_url": "https://axion1337.chat"
},
"default_theme": "aXion1337 Dark",
"show_labs_settings": true,
"features": {
"feature_qr_code_login": true,
"feature_new_room_list": true
"feature_new_room_list": true,
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_element_call_video_rooms": true,
"feature_new_room_decoration_ui": true
},
"element_call": {
"use_exclusively": true
},
"setting_defaults": {
"feature_group_calls": true,
"custom_themes": [
{
"name": "aXion1337 Dark true",
@@ -186,6 +208,433 @@ data:
"#d65d0e"
]
}
},
{
"name": "aXion1337 Light",
"is_dark": false,
"colors": {
"accent-color": "#8f3f71",
"primary-color": "#af3a03",
"warning-color": "#9d0006",
"sidebar-color": "#ebdbb2",
"roomlist-background-color": "#f2e5bc",
"roomlist-text-color": "#665c54",
"roomlist-text-secondary-color": "#427b58",
"roomlist-highlights-color": "#00000012",
"roomlist-separator-color": "#bdae9330",
"timeline-background-color": "#fbf1c7",
"timeline-text-color": "#3c3836",
"secondary-content": "#7c6f64",
"tertiary-content": "#7c6f64",
"quinary-content": "#d5c4a1",
"timeline-text-secondary-color": "#665c54",
"timeline-highlights-color": "#00000012",
"reaction-row-button-selected-bg-color": "#8ec07c",
"menu-selected-color": "#d5c4a1",
"icon-button-color": "#7c6f64",
"accent": "#427b58",
"alert": "#9d0006",
"username-colors": [
"#9d0006",
"#79740e",
"#b57614",
"#076678",
"#8f3f71",
"#427b58",
"#665c54",
"#af3a03"
]
}
},
{
"name": "Ocean Depths",
"is_dark": false,
"colors": {
"accent-color": "#2d8b8b",
"accent": "#2d8b8b",
"primary-color": "#a8dadc",
"warning-color": "#457b9d",
"alert": "#70555a",
"sidebar-color": "#e3ebe0",
"roomlist-background-color": "#eaf2e7",
"roomlist-text-color": "#1a2332",
"roomlist-text-secondary-color": "#656e74",
"roomlist-highlights-color": "#00000010",
"roomlist-separator-color": "#0000001f",
"timeline-background-color": "#f1faee",
"timeline-text-color": "#1a2332",
"secondary-content": "#656e74",
"tertiary-content": "#656e74",
"timeline-text-secondary-color": "#656e74",
"timeline-highlights-color": "#00000010",
"reaction-row-button-selected-bg-color": "#99c8c1",
"menu-selected-color": "#b6d9d0",
"focus-bg-color": "#b6d9d0",
"room-highlight-color": "#b6d9d0",
"other-user-pill-bg-color": "#d4ede7",
"icon-button-color": "#656e74",
"username-colors": [
"#2d8b8b",
"#a8dadc",
"#457b9d",
"#276c70",
"#7da3a9",
"#365c78",
"#6ab2b4",
"#76aabc"
]
}
},
{
"name": "Sunset Boulevard",
"is_dark": true,
"colors": {
"accent-color": "#e76f51",
"accent": "#e76f51",
"primary-color": "#f4a261",
"warning-color": "#e9c46a",
"alert": "#d6453a",
"sidebar-color": "#1b323c",
"roomlist-background-color": "#213c47",
"roomlist-text-color": "#f6efe6",
"roomlist-text-secondary-color": "#adb4b3",
"roomlist-highlights-color": "#ffffff14",
"roomlist-separator-color": "#ffffff26",
"timeline-background-color": "#264653",
"timeline-text-color": "#f6efe6",
"secondary-content": "#adb4b3",
"tertiary-content": "#adb4b3",
"timeline-text-secondary-color": "#adb4b3",
"timeline-highlights-color": "#ffffff14",
"reaction-row-button-selected-bg-color": "#7d5852",
"menu-selected-color": "#605252",
"focus-bg-color": "#605252",
"room-highlight-color": "#605252",
"other-user-pill-bg-color": "#786b59",
"icon-button-color": "#adb4b3",
"username-colors": [
"#e76f51",
"#f4a261",
"#e9c46a",
"#ec957e",
"#f5b989",
"#eed395",
"#ee8859",
"#eeb366"
]
}
},
{
"name": "Forest Canopy",
"is_dark": false,
"colors": {
"accent-color": "#2d4a2b",
"accent": "#2d4a2b",
"primary-color": "#7d8471",
"warning-color": "#a4ac86",
"alert": "#703126",
"sidebar-color": "#ebeae7",
"roomlist-background-color": "#f2f2ef",
"roomlist-text-color": "#22301f",
"roomlist-text-secondary-color": "#6e766a",
"roomlist-highlights-color": "#00000010",
"roomlist-separator-color": "#0000001f",
"timeline-background-color": "#faf9f6",
"timeline-text-color": "#22301f",
"secondary-content": "#6e766a",
"tertiary-content": "#6e766a",
"timeline-text-secondary-color": "#6e766a",
"timeline-highlights-color": "#00000010",
"reaction-row-button-selected-bg-color": "#9eaa9b",
"menu-selected-color": "#bcc4b9",
"focus-bg-color": "#bcc4b9",
"room-highlight-color": "#bcc4b9",
"other-user-pill-bg-color": "#c8cac1",
"icon-button-color": "#6e766a",
"username-colors": [
"#2d4a2b",
"#7d8471",
"#a4ac86",
"#2a4227",
"#626b58",
"#768162",
"#55674e",
"#90987c"
]
}
},
{
"name": "Modern Minimalist",
"is_dark": false,
"colors": {
"accent-color": "#36454f",
"accent": "#36454f",
"primary-color": "#708090",
"warning-color": "#d3d3d3",
"alert": "#752e39",
"sidebar-color": "#f0f0f0",
"roomlist-background-color": "#f7f7f7",
"roomlist-text-color": "#1c252b",
"roomlist-text-secondary-color": "#6b7175",
"roomlist-highlights-color": "#00000010",
"roomlist-separator-color": "#0000001f",
"timeline-background-color": "#ffffff",
"timeline-text-color": "#1c252b",
"secondary-content": "#6b7175",
"tertiary-content": "#6b7175",
"timeline-text-secondary-color": "#6b7175",
"timeline-highlights-color": "#00000010",
"reaction-row-button-selected-bg-color": "#a5abb0",
"menu-selected-color": "#c3c7ca",
"focus-bg-color": "#c3c7ca",
"room-highlight-color": "#c3c7ca",
"other-user-pill-bg-color": "#c6ccd3",
"icon-button-color": "#6b7175",
"username-colors": [
"#36454f",
"#708090",
"#d3d3d3",
"#2e3b44",
"#576572",
"#939698",
"#536270",
"#a2aab2"
]
}
},
{
"name": "Golden Hour",
"is_dark": true,
"colors": {
"accent-color": "#f4a900",
"accent": "#f4a900",
"primary-color": "#c1666b",
"warning-color": "#d4b896",
"alert": "#dd650e",
"sidebar-color": "#352e2a",
"roomlist-background-color": "#403732",
"roomlist-text-color": "#f7f0e6",
"roomlist-text-secondary-color": "#bab2aa",
"roomlist-highlights-color": "#ffffff14",
"roomlist-separator-color": "#ffffff26",
"timeline-background-color": "#4a403a",
"timeline-text-color": "#f7f0e6",
"secondary-content": "#bab2aa",
"tertiary-content": "#bab2aa",
"timeline-text-secondary-color": "#bab2aa",
"timeline-highlights-color": "#ffffff14",
"reaction-row-button-selected-bg-color": "#966f20",
"menu-selected-color": "#7d6029",
"focus-bg-color": "#7d6029",
"room-highlight-color": "#7d6029",
"other-user-pill-bg-color": "#7a4f4e",
"icon-button-color": "#bab2aa",
"username-colors": [
"#f4a900",
"#c1666b",
"#d4b896",
"#f5be45",
"#d18f90",
"#e0ccb2",
"#da8836",
"#ca8f80"
]
}
},
{
"name": "Arctic Frost",
"is_dark": false,
"colors": {
"accent-color": "#4a6fa5",
"accent": "#4a6fa5",
"primary-color": "#d4e4f7",
"warning-color": "#c0c0c0",
"alert": "#804569",
"sidebar-color": "#ebebeb",
"roomlist-background-color": "#f2f2f2",
"roomlist-text-color": "#1e2c40",
"roomlist-text-secondary-color": "#6b7481",
"roomlist-highlights-color": "#00000010",
"roomlist-separator-color": "#0000001f",
"timeline-background-color": "#fafafa",
"timeline-text-color": "#1e2c40",
"secondary-content": "#6b7481",
"tertiary-content": "#6b7481",
"timeline-text-secondary-color": "#6b7481",
"timeline-highlights-color": "#00000010",
"reaction-row-button-selected-bg-color": "#abbbd4",
"menu-selected-color": "#c5d0e0",
"focus-bg-color": "#c5d0e0",
"room-highlight-color": "#c5d0e0",
"other-user-pill-bg-color": "#ebf1f9",
"icon-button-color": "#6b7481",
"username-colors": [
"#4a6fa5",
"#d4e4f7",
"#c0c0c0",
"#3d5b87",
"#9dadc0",
"#878c93",
"#8faace",
"#cad2dc"
]
}
},
{
"name": "Desert Rose",
"is_dark": true,
"colors": {
"accent-color": "#d4a5a5",
"accent": "#d4a5a5",
"primary-color": "#b87d6d",
"warning-color": "#e8d5c4",
"alert": "#cb6369",
"sidebar-color": "#432132",
"roomlist-background-color": "#50283c",
"roomlist-text-color": "#f6ece4",
"roomlist-text-secondary-color": "#c0aaad",
"roomlist-highlights-color": "#ffffff14",
"roomlist-separator-color": "#ffffff26",
"timeline-background-color": "#5d2e46",
"timeline-text-color": "#f6ece4",
"secondary-content": "#c0aaad",
"tertiary-content": "#c0aaad",
"timeline-text-secondary-color": "#c0aaad",
"timeline-highlights-color": "#ffffff14",
"reaction-row-button-selected-bg-color": "#936471",
"menu-selected-color": "#815262",
"focus-bg-color": "#815262",
"room-highlight-color": "#815262",
"other-user-pill-bg-color": "#814e56",
"icon-button-color": "#c0aaad",
"username-colors": [
"#d4a5a5",
"#b87d6d",
"#e8d5c4",
"#debab8",
"#cb9e91",
"#edddcf",
"#c69189",
"#d0a998"
]
}
},
{
"name": "Tech Innovation",
"is_dark": false,
"colors": {
"accent-color": "#0066ff",
"accent": "#0066ff",
"primary-color": "#00ffff",
"warning-color": "#1e1e1e",
"alert": "#57409a",
"sidebar-color": "#f0f0f0",
"roomlist-background-color": "#f7f7f7",
"roomlist-text-color": "#1e1e1e",
"roomlist-text-secondary-color": "#6d6d6d",
"roomlist-highlights-color": "#00000010",
"roomlist-separator-color": "#0000001f",
"timeline-background-color": "#ffffff",
"timeline-text-color": "#1e1e1e",
"secondary-content": "#6d6d6d",
"tertiary-content": "#6d6d6d",
"timeline-text-secondary-color": "#6d6d6d",
"timeline-highlights-color": "#00000010",
"reaction-row-button-selected-bg-color": "#8cbaff",
"menu-selected-color": "#b2d1ff",
"focus-bg-color": "#b2d1ff",
"room-highlight-color": "#b2d1ff",
"other-user-pill-bg-color": "#99ffff",
"icon-button-color": "#6d6d6d",
"username-colors": [
"#0066ff",
"#00ffff",
"#1e1e1e",
"#0950bc",
"#09bcbc",
"#1e1e1e",
"#00b2ff",
"#0f8e8e"
]
}
},
{
"name": "Botanical Garden",
"is_dark": false,
"colors": {
"accent-color": "#4a7c59",
"accent": "#4a7c59",
"primary-color": "#f9a620",
"warning-color": "#b7472a",
"alert": "#804c3f",
"sidebar-color": "#e6e4df",
"roomlist-background-color": "#eeece6",
"roomlist-text-color": "#22331f",
"roomlist-text-secondary-color": "#6c7667",
"roomlist-highlights-color": "#00000010",
"roomlist-separator-color": "#0000001f",
"timeline-background-color": "#f5f3ed",
"timeline-text-color": "#22331f",
"secondary-content": "#6c7667",
"tertiary-content": "#6c7667",
"timeline-text-secondary-color": "#6c7667",
"timeline-highlights-color": "#00000010",
"reaction-row-button-selected-bg-color": "#a8bdaa",
"menu-selected-color": "#c2cfc1",
"focus-bg-color": "#c2cfc1",
"room-highlight-color": "#c2cfc1",
"other-user-pill-bg-color": "#f7d49b",
"icon-button-color": "#6c7667",
"username-colors": [
"#4a7c59",
"#f9a620",
"#b7472a",
"#3e6648",
"#b88420",
"#834026",
"#a2913c",
"#d87625"
]
}
},
{
"name": "Midnight Galaxy",
"is_dark": false,
"colors": {
"accent-color": "#2b1e3e",
"accent": "#2b1e3e",
"primary-color": "#4a4e8f",
"warning-color": "#a490c2",
"alert": "#6e1930",
"sidebar-color": "#d8d8eb",
"roomlist-background-color": "#dfdff2",
"roomlist-text-color": "#241a34",
"roomlist-text-secondary-color": "#686179",
"roomlist-highlights-color": "#00000010",
"roomlist-separator-color": "#0000001f",
"timeline-background-color": "#e6e6fa",
"timeline-text-color": "#241a34",
"secondary-content": "#686179",
"tertiary-content": "#686179",
"timeline-text-secondary-color": "#686179",
"timeline-highlights-color": "#00000010",
"reaction-row-button-selected-bg-color": "#928ca5",
"menu-selected-color": "#aeaac2",
"focus-bg-color": "#aeaac2",
"room-highlight-color": "#aeaac2",
"other-user-pill-bg-color": "#a8a9cf",
"icon-button-color": "#686179",
"username-colors": [
"#2b1e3e",
"#4a4e8f",
"#a490c2",
"#291d3b",
"#3f3e74",
"#776790",
"#3a3666",
"#776fa8"
]
}
}
]
}
+11 -11
View File
@@ -4,19 +4,19 @@ metadata:
name: ess-mas-values-secret
namespace: matrix
stringData:
values.yaml: ENC[AES256_GCM,data:B9nRZWCUnWxWhnlOTzSdOZrO+aiKwotKZhjYTwTBlgy9zVGoAn81yy6s2QJySeAI/rXcKamQ/P3fewDsLDzX8zxbvHS1GZ/F1fPx7H4tan9/1tu7XvNx52DWMCO8UtmpwNb1gAaa0kLbs+u1dTKA3Bk9b1gWsmP5OGo+C43knmhF3YvDOwv5wO96iaAzMwpjpxB78AkOvkshLEbP/o+z/DKsLxtoeQ0tPzkXjQj8d/2Jaj+3Ve8uqSNbnAEoL6gTDr+qvQs2JU/jvilK/fKgkR4hF54opthEMzKV7pbUYia2a+5yOmmQaBXQ5cUqi7wrTVyle/GyXHHOLAU37sGYhKyIGjgMu8rGTHMQgm3c5rGt4wLps48+LN4/S74tbTgyBf8V6hTGfNXIpMDkjXpPujZLzCKSx8V+7NlQDcs8FGobOLdmmcBzj3WGVMJLt/IA1/hv4xj4sP7CeQqG0UpWhJLXxY+ghCGdGJ7dA2wme+hreb1ywrhgVVnGH+plSG9BmTIye4sgCJwnZ9ER6T42ptsw70GaqfFazsgebeKEM+grJLfx+f4YyPexDaGZbrrZ5nxvOtvVz6d8Cx4zuP7jt+7RwzZiXPlYjsgvwwypuafIh/Uk0ULeFiSRLKLEGItRcOTdlVUbOOP9bW1Is42PzsnDnx0Mc0v4LzQCQTYJL3amfgOeGVJ1I8zS6okM8sZE3F0jzGvaFTNbeMiIkealUhjQrhF/5MweiNUIOUzAp/DLFT2mBg9g24imLtDCglEV0WwVQACd9GzQLNRnjNmaId7ISeYfUH/gORqyUJlICaRFB1xH+MMzy09tP2zuYcY+sIFJotYF29J0wC8oQS2KsKQiHEPVTLYTzDsB0RU4pGD6471rgIByXQrlmawo9HtjZxfZuzw7+AEa9uOBohbNkOwDUQGoLqgssAefAz4H/LC0NmBONR7iVzYcWv3RgDhsiafkUq6PgyXYEleB7Euk1wxX9mxUEqfuVMbeDceog2Q41vWBmFkFnSUYXHbN/Ff8DfWe+zjcSKDzjSchU54iYTbuuKnDo+G/jEk/FNGcp/xOh0/JyYWcqO5DozBPlIJ5QxgWkQTWC8oSM1QmTxiSj+FYDZFdyOXSXsqF8a9BJaEh5nACUbqwpx+UnRnJpcIcOlYasDwThNSNZIkpqqf2e7vsZHMTakkTbHOcWB7IiYWOCQLdzStQn3URWIobKn3T+mbbDerWzF2a9UEBbCwlTbA/OoyeWW4j1uPo29ek6F6Lb7JdxibNO6U5JIZkYP92Ci8W7skFAn7rEe203qnlvzoxhKxE+dA5rM+Tr4FWz9qAa/u3EhPxVvPK+0Wpb6HAUbFGbm+MSD7uqlcnvVxZfU2dSbpb/J56Bq8vzmJKcaStxoE4lDG+uwa2I/o0f1BMdwWvnmKEAJa593NYS9lw4s2dR3IwFkeE8hp3Q6yxaFk5v9m5/ivXOwiaOJ238HqDBJrfCQidIe+RaqydPxqqOj7WV1YZFU6DxZJ30LcyH45MJQqC5TGz2W1Unwljl3arh/hQRb1G3eGNw4kO86CipioKTeDBPpoKfgNFbz8qiNPSCRXPtJve+OOptDojLJ5iR+HO6olmSu+OGww7+BwTvBraW/xIAGq7y/gzZ8aMgsqIMyHJZiuC9+uQwquKiFEZ5pD5vAe0R9ROdFHgWsdKdEXwJtaCqBAfqWrZkrgoeWFsPYFwT3BhusQfMyWKv6SSOmWk2bx4FZ74+HeZcTsJWVcCmF4dbqfDvM5sz3b4Mek0wjUZGKtVyfFQ1VoZm6/G6UgCEt25A37AO11H/sW90Bs2nSpbOJFbgeBsNkXMptWrCyyHehof+JWeGuTFTg3etsaBgW4b1rJRzbJUQHJKVXjIOPZIQNd0WS7y+IDheDovQobfjSWn6TRVnXvEnzaD5WxY54EqOivAyv1U7/r7KMzo26Bp9s7ZQX/UeNmTn1dgkZoWNpTV8LkcrbRgIpkSNG9Pj+6GOtdUIxobtLeZ9h5Oso/TSKoDaHbkfoSVhW3FgQ7aJHHvx0j2qp/hVQfvABCPILW2DJvtNyIOU5zgV7yHJF/Dw7PpJfw636WxQHaTehbOav1ImE0FX0VF6NmkhuEpPEVHr0lejGse+0XkVzMuxoi/bE4h7WFIfbiqdlw=,iv:KnhgvxrFd+6BEBuBUKtQgKEfx81G9uJ+CY4Wrm43Pjw=,tag:1OQs0MQ1Gvf1LwXLaGwyjQ==,type:str]
values.yaml: ENC[AES256_GCM,data:jKArnf1jeZwgTcCoktZozMHJ8wBulzvI90G5yaY800E0wA0Fel14DDVpJ8u1kyRXKdT3A3YGGdDExuEEC5ZWfU+o6GGlkpLzJKu9MzQyDSsU56nrkn+QB90Ky5m3fFipoINaRjlycafuY2JWLQfdZH2is5phLTRW3vrZtLdnujFqkWKoAISPST3IaN4Ked2BxC/ZzrgTK46PAale1JC2vbxFAPtNTb2Qf0sZ4SGGJfO7NYumUtIc5Lq51x5gHHChRDmi2vBD4/EhPIglnodTW3opUOVZ8V/DIt0zev/10PNvn7zoWrCFRgWHcl1wkXL8RKwJohGf8u00Lom7D/YWaXCq4P6/ykMMLyf19MvMNDb0NJwhdPGnjZExkLmFYRL1kfH6yQVijv8Oj3ySVcvIISau9FWxPHQljhRIFTlpAjUPwZFD/mvg4nf9li5dFdU3RgOx9vgZayb5lM7UhP2ubvSvZqaYRrgBipx3jyNoj6CQ3/fhmpDQB2nOxwQx6OLkAgBDm87Pnxv40U+hO40DmLPtuv5h3KVwzf0Sagx44OevkEeNE1iYMgdWLk4In7Mj1gQe0jRBOE0Ys9sVJ3YiKqQEi4Ul8PlfrT+SFrbI/iZ+Q7NjyfiXLYIAvrAU2DW2RzVA6lO5poZ6lP3BhnA3LzolFgPZh1sE21U479fGY+aZwkdwpQCTAl126/CeN1f6SZXjfYUEBl9lOEL1kPZ0ujyt6HjuvzWJM3fMw+F6MkXayrN8KUBoln8Vn1ma1wxYhvRc8XjAUhU8/vrwTFzGK9b9mg/qLGB1G0CJN8ZV/RHEM0JMGW73Hqg/pYs51GmqQfcZGL4lUFKcdQwjEeWA32A4Eb69GZlRtnhtofgHjFTjSlOChMbBzRQFXV4pOCvc2rO4nPYu8FT6yIYmHaKezN2sVWdZ0dz3NUFZD8nHD62EcTnpYAPvtIeDuyL0dqnsiNeTfeYvWZh+562iQkPiIHSUKvwffrC/EnTEHwi4IBHjEYxGv/CQYcCU2BXw/vKKCJVVcZ5FB7l6PbofreruKxyDof5GWjSehhddYj2MItt6wm2G6tLrIm9zBW5QQVsMc+RSBR5XnglyVFsRXCdWbFfKdIOTBRITH+qpgKLozeh4cVGGSFao4QSzgLgIoEd8xUx9sc8tf65Wkb0Z/qNGRKhBOuwzSYKZSlJF9BvAip6mb9befEnGOOkz6NNWi9/mItLochsKru+V/WhjEykVdHRkxZy5uj4o7hyiRlkCax6e2oSHshH/0u0cv9gp4cNH07JFHiqnrr/OzSizyMHelQF6WLoQXliLzxhNdqiSTsFyxn7DfZLjUokJBk7HVKctO7Hg+PeOuQEXydhJAUhxSds4NQw2Gc3ArKF1NK0F2kjqSzNgtuk2MYZ5nLIqM4inkulWuAT3sZKO05k0kbV+gZftI1khKJ4EPD7+X3sOYYsuxYsNPP8afzQkNEpjKi70AI38i9CuZlZFkmaQ8XJpdfQqXYMecy0khWxTDeLP8qa4P6L8H95py5+fhEih/VkFJVYmeJdYbiz7/nPUh7kTKqE3/bCjj0y6TfrdJvzMm8FTCtQRglO5feUg1SKrEckL8VEvaTawDMHI4psuiCukFoDhs0YauAVYUIfYfzCj+KCp3yn09huCl0hZ79PMO5ddHOsLI+t6M16MXAEbQQzXmfVd008RG9/a1WNzYuc+oEq/hgQ/PtlaRRsLqRDWBBR97/MpedpE+cVAZoJs/pyiGepr+wC5mP8Vg1aRUHnetoARFX+cquqJ0N1VbSUE0QxmmJ/mCQTumY1LtRG1nWgw7NuJ/N7wXSXUkwVjLulsZGI6Cc1xt8xrUfgSky8EDR0zRd2ogPAN+Nm5talaHxxnmDau3wMHFufrsvLy8+pqqJHY/PEIBgf/8yZGk9PwLV4FkeeqAelVyYnGXDdqpsYI8WtgKOIr52TAoq4hKSpNGCJsT634nSP8ZNlZBrWpnrdYvNiWe7tqS+hIz+uY53baYNM8xZiRtvqSL9S3JUsqno49B7DreRCLtypKCSYjQlFFgPt6TlaN9rVWof1mSOtKTQnyOn0zlVgdO6+PB0wN4JcwYdYn5TG55/EE2BDZCceDMt9tryUGWmP6Yvhn0T3JD9mcRmeQxH9O1nipuWuPWQek,iv:YhS6z4F2E7EvhpY2A5rN9mnnllmiieDCUQQEBykJxUw=,tag:Z5i5cJ77CuxMt2ChVIYNaw==,type:str]
sops:
age:
- recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2RHgzUE5QVVVCdkJ5aWNj
VmFHTUs2TXpXYzE2VmJnMHFNek1RT0FnUVVFCmJqRHUzY2F3WWt1L1FLbVpDWWFO
QzN1akRQdjBBVENIWHhGOGgrZFdqZGsKLS0tIEVRMGRrS05zS1ErdFZZUmZ4TUVv
U1dXMWltRlY1cGx2TFI4YjRreVBPQVkK9UFiAiSANa7HekQxufsFSkMQoL18kGmi
cP0jf27NGFpAjC8AmuMWgMydYDGXyRgFRU5JDqGCYAsgZsrGgjIWkA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsWDNpQm90aHZLR1MwendM
RC8rTE4rS3laK281THE2Mnp4b2hTdjhQVjFFClRGTExuWVFNVGZGTURWNyt1Qkxy
YnBEaytBTjJqS25zeG5rczVLR256OEUKLS0tIEdBdHNsWit6MjlVUDVSWmZJRjJM
NGN1S3hpNThQSERDZGFtRjBaWi9mYkkKSiRfp0qHxQ+CAKhAjJRhnHl30iteBQuS
Ha8G0sJNHDk/+vg4/3LR+D1hv2i6vbI/Mo0W85GiCpGBu0hajTxgbQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-15T09:05:44Z"
mac: ENC[AES256_GCM,data:gFPsb3LCjoPglcPEmLEe8hFQSsrcsZCMtVCf7L8jNLEgsL5XUIEF/BEoT7I+wPisRclAtq2qOkBd3TqmxQWAaPbRQ0+RDHU49XD5rWavDv7/CA1QUCLL/RNTbuURyS9iri/F4xneeYLwKJxJCgmMEiaqRPaAnHioxFtPreEiREg=,iv:FgWNZJUOydGY/m0SlZLWtWefIstMG7ccju6h8BLuVho=,tag:MMXoxQIA8ZNl5qBJjuzdpw==,type:str]
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
version: 3.12.2
lastmodified: "2026-08-11T10:29:33Z"
mac: ENC[AES256_GCM,data:TPCj47C4tHcT71r4Y4BsTCFwRnj3/olDJKjpBW6cXUsV4ee10cmITt7NKoHXxkuux62VPMuL5SMv1Y0pETxVFVpXxIAvonedy/VpZkPbA605rQ7G0lNzcz/NRJVQt0mq5KKIhRsDtovRH8SD6/D3CjATNzvP77nfEaJZclvxSik=,iv:A6Y3ZdMYuB0qGTdJv3X4mr9/tva/7HHqzYRFvrorE3E=,tag:bhXI97dA2KSyZcF66R1nqw==,type:str]
version: 3.13.3
@@ -10,6 +10,22 @@ data:
rootLevel: INFO
levelOverrides:
synapse.media.url_previewer: DEBUG
# ClamAV media scanning module (Issue #19) - mounted read-only from a ConfigMap
# (synapse-clamav-module) since the container runs with a read-only root filesystem
# and we avoid a custom Synapse image; PYTHONPATH picks it up for the `modules:`
# block below.
extraVolumes:
- name: clamav-spam-checker
configMap:
name: synapse-clamav-module
extraVolumeMounts:
- name: clamav-spam-checker
mountPath: /extra-modules/clamav_spam_checker.py
subPath: clamav_spam_checker.py
readOnly: true
extraEnv:
- name: PYTHONPATH
value: /extra-modules
additional:
url-previews:
config: |
@@ -53,20 +69,38 @@ data:
auto_join_rooms:
- "#onboarding:axion1337.chat"
auto_join_rooms_for_guests: false
# Foederation geschlossen (Entscheidung sorb 2026-08-19, management #0060,
# ADR-0021). Leere Liste = mit KEINEM Server foederieren; ein Eintrag hier
# oeffnet gezielt fuer diese eine Domain.
#
# Grundlage war eine Messung, keine Meinung: in vier Monaten Betrieb null
# Eintraege in `destinations`, null fremde Nutzer, null Raeume mit fremder
# Beteiligung - bei gleichzeitig oeffentlich erreichbarer Foederations-API
# (die Delegation fuehrt sie ueber 443, 8448 ist ohnehin zu).
#
# ⚠️ NICHT betroffen und darf es auch nicht sein:
# /_matrix/federation/v1/openid/userinfo. Ueber diesen Endpunkt prueft der
# MatrixRTC-Authorisation-Service (lk-jwt-service) die OpenID-Tokens, und er
# ruft ihn ueber den OEFFENTLICHEN Namen auf - das Deployment hat keine
# hostAliases und ClusterFirst-DNS. Synapse bedient ihn ohne
# X-Matrix-Signatur (REQUIRE_AUTH=False), die Whitelist greift dort also
# nicht. Wer hier auf Pfad-Ebene sperrt statt ueber diese Liste, legt die
# Gruppen-Calls lahm - genau der mrtc-Ausfall, nur mit anderer Ursache.
federation:
config: |
federation_domain_whitelist: []
room_publish:
config: |
room_list_publication_rules:
- user_id: "*"
action: allow
turn:
config: |
turn_uris:
- "turn:turn.axion1337.chat?transport=udp"
- "turn:turn.axion1337.chat?transport=tcp"
- "turns:turn.axion1337.chat?transport=tcp"
turn_shared_secret: "cab3c8408363515d9b4cdc3384a1f76ca17a973242fdfdc72b67ac4d86158527"
turn_user_lifetime: 86400000
turn_allow_guests: false
oembed:
config: |
oembed_enabled: true
oembed_enabled: true
clamav-module:
config: |
modules:
- module: clamav_spam_checker.ClamAVSpamChecker
config:
clamd_host: "clamav.matrix.svc.cluster.local"
clamd_port: 3310
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: draupnir-data
namespace: matrix
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 2Gi
+22
View File
@@ -0,0 +1,22 @@
apiVersion: v1
kind: Secret
metadata:
name: draupnir-config
namespace: matrix
stringData:
default.yaml: ENC[AES256_GCM,data:q8jaWkZtjMQFCokSMVLvjRC53qou0AFtMei0LmXR7X7ync2NYDv9HJcnkxRVH9dfbeYGtUub4H5QYJatbX0nJqS0dii3PxHF+rqxlbnBsrfL6lDU6RBvIZnninPHHx0q6YacIPE1u4bX+9VkHZ4CQCsJfYVCX3zcGk3JjYWwF90FFANLH/DJu6trALml7CH7yZiSrgETFtffSn+ghI/CJqAMB6LRl7xG8xD2duE6HdNTztbJn7s0cntdNtU3MmJik8q45pNnnEZSAAXWqQUJ15IWVAWKpADA1IyUemYT0DcdEW1ZZywUhRaDhnF111RjjixVj+wPPYnkCpEoTAJ0cSBna2BAzBVda9ztvjvYXnVY0tL1eqi+VlqH09JPaozLlcLeje62YH8i05wW8SXlU7ojiXZdHx1JiOxQZu3ec/xuBOq0CWKWBQH/nOOL87NwhUoNMqQLgpf4,iv:SfBDZH67aptbRas5mPlBVsA00EfYJ+evzYtoyzhOH4o=,tag:DOfNLaZ2QJncEKt53ELokA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBORHJrRGUraSs0dXc3Y2Zu
emZtZExCbGQ0dkh2d3NTZWdjZlVPSXp3NEdNCmRsL3VpdjJ2Q2NWN2Z4TnBwTm9u
T2YvL2ZTejhYVkprdXJQM3F6dlE1NlEKLS0tIFZwaFgwRTA1b2JiU0RMVmQ3clhx
ZkdURVljTHJjOE1xRmczV3hic2x3U3cKMVcGRX9NQlLefQrjqhYWPH+DyF9N4nw8
RxRVkw7DPbrfP8Bm57oasBeUya73OxVDZAj7UM/B1MXS/vtU3mXFmw==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-07-29T11:16:15Z"
mac: ENC[AES256_GCM,data:4EvnrSUhi0vQpjaJGHF258qKOc4lO8HjmVP0JaSSpNvD8lVTwdM1E5hFlqOKmgyT6naHL7HMzQ63AGCRGG/YrEvWG/tKgMtSvuyfDOMu8nCy6ksW2qOB6YRDQtTGLHIth9p2lVlSYQLmsSONj8Ve9Ftp4/uiJ4fiR9HpYnvk/II=,iv:hYD3oDv23jcyh/HWV7tsOAin9otzjRtOfLpw1zWrOpA=,tag:8zOO16eubuHoMpUelV20yw==,type:str]
version: 3.13.3
+67
View File
@@ -0,0 +1,67 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: draupnir
namespace: matrix
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: draupnir
template:
metadata:
labels:
app.kubernetes.io/name: draupnir
spec:
securityContext:
fsGroup: 1000
containers:
- name: draupnir
image: gnuxie/draupnir:v3.1.0
imagePullPolicy: IfNotPresent
# v3.x dropped NODE_CONFIG_DIR auto-discovery in favour of an explicit CLI flag
# (confirmed by extracting dist/config.js from the image - getConfigPath() only
# checks --draupnir-config/--mjolnir-config, throws otherwise).
args: ["bot", "--draupnir-config", "/data/config/default.yaml"]
ports:
- name: healthz
containerPort: 8080
volumeMounts:
- name: config
mountPath: /data/config/default.yaml
subPath: default.yaml
readOnly: true
- name: storage
mountPath: /data/storage
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 384Mi
# healthz reports 418 (not just a plain failure) until Draupnir finishes its
# initial room-state sync with the homeserver - generous initialDelay/failureThreshold
# avoids a restart loop while that's still in progress on first boot.
livenessProbe:
httpGet:
path: /healthz
port: healthz
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 10
readinessProbe:
httpGet:
path: /healthz
port: healthz
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 10
volumes:
- name: config
secret:
secretName: draupnir-config
- name: storage
persistentVolumeClaim:
claimName: draupnir-data
+74 -3
View File
@@ -4,7 +4,11 @@ metadata:
name: matrix-stack
namespace: matrix
spec:
interval: 5m
# Shortened from 5m to match production-apps Kustomization's 1m interval - narrows the
# window between coturn (Kustomization-only, no Helm indirection) and synapse-main
# (behind this HelmRelease) picking up a rotated TURN secret after Issue #38's
# automated-rotation PR gets merged. Self-heals either way, just faster now.
interval: 1m
chart:
spec:
chart: matrix-stack
@@ -25,7 +29,10 @@ spec:
- kind: Secret
name: ess-mas-values-secret
valuesKey: values.yaml
- kind: Secret
name: synapse-turn-secret
valuesKey: values.yaml
values:
# Top-Level: serverName das ist dein Matrix-Homeserver-Name
serverName: axion1337.chat
@@ -74,7 +81,71 @@ spec:
image:
registry: rohana.axion1337.de
repository: sorb/threadnet-web
tag: v0.2.3-elementcall-h264
# v0.6.0-rc.2 = Anschluss an Element Web v1.12.26 (ADR-0022). Ein echter
# Merge-Commit statt Cherry-Picks; die Fork-Patches mussten umziehen,
# weil Upstream MImageBody.tsx geloescht und den Raumlisten-Inhalt nach
# RoomListItemContent ausgelagert hat. Betroffen sind genau die zwei
# Stellen, die die Abnahme pruefen muss: die ClamAV-Fehlermeldung im
# Bild-Pfad (ImageBodyViewModel.computeErrorLabel) und die
# Call-Teilnehmerliste in der Raumliste.
# Am 19.08. ausgerollt und nach wenigen Minuten zurueckgenommen: die
# Raumliste stuerzte bei jedem Eintrag ab (react-soft-crash), weil in
# RoomListItemViewModel.ts eine getValue-Zeile auf den von Upstream
# entfernten Labs-Schalter feature_room_list_sections stehenblieb -
# Sektionen laufen dort inzwischen ueber RoomList.showSections. Eine
# Leiche aus der Merge-Aufloesung, die kein Build fangen konnte:
# getValue nimmt einen String und wirft erst zur Laufzeit.
# rc.3 = derselbe Merge ohne den Rest, plus ein typecheck-Job, den
# docker_web als needs fuehrt: kein Image mehr ohne Typpruefung. Der
# web-Job baut nur, webpack wirft Typen weg - tsc hatte den Fehler die
# ganze Zeit gemeldet, gefragt hatte ihn niemand. Massstab ist "kein
# Fehler ausserhalb von node_modules", weil Upstream v1.12.26 selbst
# nicht typrein ist (matrix-js-sdk 42.2.0, in einem sauberen Checkout
# gegengeprueft).
# v0.6.0 = Abnahme auf rc.3 bestanden (19.08.), derselbe Quellstand
# 8ca03fe unter Release-Nummer. Geprueft am laufenden System, nicht nur
# am Build: Raumliste laedt, ClamAV blockt beim Senden (ein Scan-Aufruf,
# kein Upload), abgewiesene Datei zeigt die Meldung, und das .png wurde
# zugestellt, beim Herunterladen abgewiesen und die Meldung gerendert -
# damit ist der portierte Bild-Pfad belegt, nicht nur vermutet. Auch die
# Call-Teilnehmerliste, der zweite umgezogene Patch, steht richtig drin.
# Rueckhebel bleibt der Tag-Revert auf v0.5.4.
# v0.5.4 = Sender-Verifikation (threadnet-call fee9866): auf Safari
# uebersprang LiveKit den Track-Tausch stumm (sender?.replaceTrack),
# das rohe Mikro blieb auf der Leitung. Der Fork prueft und erzwingt
# den Tausch jetzt; die Konsole weist den Sendepfad aus.
# v0.5.3 = KI-Geraeuschunterdrueckung freigeschaltet (threadnet-call
# e3f8a85): Abnahme im Call zu zweit bestanden 2026-08-17. Checkbox +
# Regler in den Call-Einstellungen (Audio-Reiter). Rueckhebel bei
# Regression: Feature-Tor im Fork schliessen, kein Deployment-Revert.
# v0.5.2 = KI-Filter-Anbindung Weg B (threadnet-call df4e5ee): Filter
# haengt sich NACH der Publikation an den Mikrofon-Track, eigener
# AudioContext nur dort - kein webAudioMix, kein processor-Schluessel in
# den Capture-Defaults in irgendeinem Zustand. Tor geschlossen: fuer
# alle Nutzer verhaltensgleich mit v0.5.1; Test-Client per zwei
# localStorage-Schluesseln. Tor-Oeffnung erst nach Abnahme im Call.
# v0.5.1 = Entmuten-Vorfall aus v0.5.0 behoben (threadnet-call dcc8643):
# Aus-Pfad der KI-Geraeuschunterdrueckung wieder identisch mit Upstream
# (kein processor-Schluessel), Feature hart stillgelegt bis zur
# webAudioMix-Entscheidung - neutralisiert auch Clients mit noch
# aktivierter Einstellung im localStorage. Regressionstests decken beide
# Faelle ab. Abnahme: Call zu zweit nach dem Rollout.
# Historie 2026-08-16: v0.5.0 brach das Entmuten beidseitig und wurde v0.5.0 brach das Entmuten - es wurde nie ein
# Audio-Track veroeffentlicht (SFU-Log: kein einziges "published"). Erstes
# Produktivimage mit dem KI-Filter im Audio-Pfad; v0.4.3 (embedded .7) ist
# der letzte Stand, mit dem Calls nachweislich liefen (09.-11.08.).
# Ursache offen - siehe #0054. NICHT wieder anheben ohne Call-Test zu zweit.
# v0.5.0 = KI-Geraeuschunterdrueckung im Call-Widget (ADR-0018): erste
# Funktionserweiterung seit dem Rebrand, daher Minor statt Patch. Das
# Image traegt jetzt 23 MB Modell-Assets unter
# /widgets/element-call/assets/dfn3/ - sie werden erst beim Einschalten
# des Filters geladen, nicht beim Seitenaufruf.
# v0.4.0 = Rebrand sichtbar: zentrierte Icons, Markenfarbe #ed4f4c,
# About-Attribution unter der Client-Version (ThreadNet-Web#10).
# Die Linie beginnt bei v0.3.0, dem ersten kanonischen CI-Build aus
# apps/web/Dockerfile - er loeste die Derivat-Images ab, deren
# Entrypoint ohne Exec-Bit /config.json still brach (ThreadNet-Web#8).
tag: v0.6.0
ingress:
host: axion1337.chat
View File
View File
+78 -3
View File
@@ -1,8 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Patch: Fügt einen Checksum der element-values.yaml zur HelmRelease hinzu
# Damit wird Flux die HelmRelease neu-synced wenn sich die ConfigMap ändert
# Patch: Fügt Checksums der element-values.yaml und des turn_shared_secret zur
# HelmRelease hinzu. Damit wird Flux die HelmRelease neu-synced (und synapse-main neu
# gestartet), wenn sich die jeweilige ConfigMap/Secret ändert - siehe Issue #38's
# Rotations-Mechanismus, der turn-secret-checksum bei jeder Rotation bumpt.
patches:
- target:
kind: HelmRelease
@@ -12,6 +14,9 @@ patches:
- op: add
path: /metadata/annotations/element-config-checksum
value: "401f8a87d0ef5d91d2e5032d4aede42c"
- op: add
path: /metadata/annotations/turn-secret-checksum
value: "05aad8b742fb02c42f4c1a5629ae31e1"
resources:
- matrix-postgres-auth.yaml
@@ -26,8 +31,78 @@ resources:
# TURN Server für WebRTC
- coturn-secret.yaml
- coturn.yaml
- synapse-turn-secret.yaml
# HelmRelease (muss ganz unten stehen, damit die ConfigMaps vorher da sind!)
- element-server-suite.yaml
# Custom Apex Ingress für Element Web + Well-Known auf axion1337.chat
- apex-ingress.yaml # Custom Apex Ingress für Element Web + Well-Known auf axion1337.chat
- networkpolicy.yaml
- networkpolicy.yaml
# Backup zur Hetzner Storage Box (Issues #6 + #15)
- synapse-backup-secret.yaml
- synapse-backup.yaml
# Monatliche Restore-Probe: spielt die Sicherungen isoliert zurueck (#0030)
- restore-drill.yaml
# Automatisierte TURN-Secret-Rotation (Issue #38)
- turn-secret-rotation-secret.yaml
- turn-secret-rotation.yaml
# Draupnir Moderationsbot (Issue #18)
- draupnir-secret.yaml
- draupnir-pvc.yaml
- draupnir.yaml
# ClamAV für Media-Scanning via Synapse-Modul (Issue #19)
- clamav-pvc.yaml
- clamav.yaml
# Client-seitiger Scan-Dienst für verschlüsselte Räume (Issue #19-Erweiterung)
- clamav-http-scanner.yaml
- concierge-bot.yaml
# 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-git-secret.yaml # SOPS, Git-Storage-PAT nach Gitea (ADR-0015)
- wikijs-postgres.yaml
- wikijs.yaml
- wiki-ingress.yaml
- wikijs-config.yaml # Konfig-Job (headless Setup + OIDC + Rollen)
- wikijs-postgres-backup.yaml # Nächtliches Borg-Backup der Wiki-DB (#0048)
# 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:
- clamav_spam_checker.py
options:
disableNameSuffixHash: true
- name: wikijs-config-script
namespace: matrix
files:
- wikijs-config.py
options:
disableNameSuffixHash: true
# Gemeinsame Branding-Assets (eine Quelle). Binärdateien -> kustomize legt sie als
# binaryData ab. MIT Namens-Hash: ändert sich ein Asset, zieht der Deployment-Verweis
# nach und der Pod startet mit dem neuen Bild neu. Kann später auch in Authentik/Element
# gemountet werden, um dieselbe Datei nicht mehrfach zu pflegen.
- name: platform-branding
namespace: matrix
files:
- branding/logo.png
- branding/alpenglow.jpg
- branding/favicon.ico
- branding/favicons/favicon-32x32.png
- branding/favicons/favicon-16x16.png
- branding/favicons/android-chrome-192x192.png
- branding/favicons/apple-touch-icon.png
- branding/favicons/mstile-150x150.png
+170
View File
@@ -109,6 +109,24 @@ spec:
port: haproxy-403
- protocol: TCP
port: haproxy-wkd
# Draupnir (Issue #18) calls Synapse's client-server API directly, in-namespace -
# without this it would be silently blocked by the default-deny policy.
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: draupnir
ports:
- protocol: TCP
port: haproxy-synapse
# Client-Scan-Dienst (Issue #19-Erweiterung) validiert Access-Tokens gegen Synapses
# eigenen /whoami-Endpoint statt eine eigene Auth zu bauen.
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: clamav-http-scanner
ports:
- protocol: TCP
port: haproxy-synapse
---
# account.axion1337.chat (Traefik) + matrix.axion1337.chat (also routes to MAS for some
# paths) + synapse-main calling MAS's internal port for session/token introspection.
@@ -258,6 +276,9 @@ spec:
- podSelector:
matchLabels:
app.kubernetes.io/name: matrix-authentication-service
- podSelector:
matchLabels:
app.kubernetes.io/name: synapse-backup
ports:
- protocol: TCP
port: 5432
@@ -291,3 +312,152 @@ spec:
# Note: coturn runs with hostNetwork: true, so NetworkPolicy does not apply to it at all -
# it's already gated by the Hetzner Cloud Firewall instead. Nothing to write here.
---
# ClamAV (Issue #19): only Synapse's check_media_file_for_spam module calls this, over
# clamd's plain TCP protocol on port 3310. Nothing else needs to reach it.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-clamav
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: clamav
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: synapse-main
# Client-seitiger Scan-Dienst (Issue #19-Erweiterung) braucht denselben ClamAV.
- podSelector:
matchLabels:
app.kubernetes.io/name: clamav-http-scanner
ports:
- protocol: TCP
port: clamd
---
# axion1337.chat/_scan (Traefik) - client-seitiger Scan-Dienst, direkt vom Browser aufgerufen.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-clamav-http-scanner
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: clamav-http-scanner
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: http
---
# wiki.axion1337.chat (Traefik) -> Wiki.js (ADR-0014, #0048)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-wikijs
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wikijs
policyTypes:
- Ingress
ingress:
- from:
- 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
---
# Wiki.js -> sein eigener Postgres (nur von Wiki.js-Pods erreichbar)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-wikijs-postgres
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wikijs-postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: wikijs
# Nächtliches Backup (wikijs-postgres-backup.yaml) braucht denselben DB-Zugang.
- podSelector:
matchLabels:
app.kubernetes.io/name: wikijs-backup
ports:
- protocol: TCP
port: postgres
---
# Egress: alles bleibt erlaubt AUSSER dem Metadaten-Dienst (management #0088).
#
# Warum so schmal? Der Lehrbuch-Schnitt (0.0.0.0/0 except RFC1918) haette hier zwei
# Dinge zerrissen, beide ueber 10.0.0.3 (CFGMON im privaten Hetzner-Netz): Alloy
# schreibt Metriken und Logs dorthin, und der TURN-Rotations-CronJob erreicht Gitea
# ueber einen hostAlias auf dieselbe Adresse. Private Netze bleiben deshalb offen.
#
# 169.254.0.0/16 ist Link-Local; darin liegt bei Hetzner unter 169.254.169.254 der
# Metadaten-Dienst, aus jedem Pod unauthentifiziert abfragbar (nachgemessen 2026-08-19:
# HTTP 200 mit instance-id, hostname, region, MAC, Netzkonfiguration). userdata und
# public-keys waren LEER - es liegen hier also keine Zugangsdaten offen. Der Gewinn ist
# entsprechend bescheiden; der Schnitt kostet aber nichts und schliesst die Klasse.
#
# ACHTUNG beim Erweitern: Sobald eine Egress-Regel fuer einen Pod existiert, gilt fuer
# ihn Default-Deny fuer alles NICHT Aufgefuehrte. Die drei Bloecke unten sind daher
# Pflicht, nicht Bequemlichkeit - insbesondere DNS: fehlt es, steht alles, und der
# Fehler sieht wie ein Anwendungsproblem aus, nicht wie eine Firewall.
#
# Rollback: diese eine Policy im Namespace loeschen.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: egress-block-metadata
namespace: matrix
spec:
podSelector: {}
policyTypes:
- Egress
egress:
# 1. DNS - zuerst, weil ohne DNS nichts geht.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# 2. Cluster-intern: Pods und Services (inkl. API-Server 10.43.0.1).
- to:
- ipBlock:
cidr: 10.42.0.0/16
- ipBlock:
cidr: 10.43.0.0/16
# 3. Alles uebrige - Foederation, ACME, SMTP, Registries, privates Netz -
# ausser Link-Local.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.0.0/16
+121
View File
@@ -0,0 +1,121 @@
# Monatliche Restore-Probe (#0030): spielt die Borg-Sicherungen in eine Wegwerf-Postgres
# IM POD zurueck und besteht nur, wenn wirklich Zeilen ankommen. Die Produktion wird nicht
# angefasst - es wird ausschliesslich aus den Repos gelesen.
#
# Warum automatisch statt "einmal im Quartal dran denken": eine Sicherung, die nie
# zurueckgespielt wurde, ist eine Vermutung (#0030) - ein Pruefrhythmus, den niemand
# ausfuehrt, ist genau derselbe Fehler eine Ebene hoeher.
#
# Abgedeckt sind die unersetzlichen Daten: synapse + matrixauthenticationservice und das
# Wiki. Authentik ist bewusst nicht Teil des automatischen Laufs (Flows/Provider liegen
# als Blueprints deklarativ im Repo, die DB ist also weitgehend reproduzierbar); dafuer
# gibt es die Stufe 3 in notfallhandbuch/notfall.sh auf Zuruf.
#
# Fehlschlag = der Job schlaegt fehl. Alarmierung dazu: threadnet-operating,
# monitoring/prometheus/alerts.yml (BackupJobFailed / RestoreDrillStale).
apiVersion: batch/v1
kind: CronJob
metadata:
name: restore-drill
namespace: matrix
spec:
# 4. des Monats, 04:20 - deutlich nach den naechtlichen Backups (03:00/03:15/03:30),
# damit die Probe den frischen Stand zieht.
schedule: "20 4 4 * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app.kubernetes.io/name: synapse-backup # NetworkPolicy/Egress wie die Backups
app.kubernetes.io/component: restore-drill
spec:
restartPolicy: OnFailure
containers:
- name: drill
image: rohana.axion1337.de/sorb/axion-backup:v2
env:
- name: BORG_PASSPHRASE
valueFrom:
secretKeyRef:
name: synapse-backup-credentials
key: borg-passphrase
- name: SSH_PRIVATE_KEY_FILE
value: /secrets/ssh/ssh-private-key
- name: SSH_KNOWN_HOSTS_FILE
value: /secrets/known-hosts/known_hosts
command: ["sh", "-c"]
args:
- |
set -eu
export BORG_RSH="ssh -i $SSH_PRIVATE_KEY_FILE -o UserKnownHostsFile=$SSH_KNOWN_HOSTS_FILE -o StrictHostKeyChecking=yes"
export PGDATA=/scratch/pgdata
mkdir -p "$PGDATA" /scratch/sock
chown postgres:postgres "$PGDATA" /scratch/sock
echo "[drill] Wegwerf-Postgres starten"
gosu postgres initdb -U postgres >/dev/null 2>&1
gosu postgres pg_ctl -D "$PGDATA" -o "-k /scratch/sock -h ''" -w start >/dev/null
# repo:datenbanken
for pair in "synapse-backup:synapse matrixauthenticationservice" "wikijs-backup:wiki"; do
repo="${pair%%:*}"; dbs="${pair#*:}"
export BORG_REPO="ssh://u641795@u641795.your-storagebox.de:23/./${repo}"
cd /scratch && rm -rf scratch
latest=$(borg list --last 1 --format '{archive}' "$BORG_REPO")
echo "[drill] ${repo}: Archiv ${latest}"
borg extract "$BORG_REPO::${latest}" scratch/dumps
for db in $dbs; do
# pg_restore/psql laufen als root und verbinden per Socket als
# postgres (initdb setzt lokal 'trust') - sonst scheitert das Lesen
# der root-eigenen Dump-Dateien.
dropdb -h /scratch/sock -U postgres --if-exists "$db"
createdb -h /scratch/sock -U postgres "$db"
rc=0
pg_restore -h /scratch/sock -U postgres -d "$db" --no-owner \
--no-privileges "scratch/dumps/${db}.dump" >/scratch/restore.log 2>&1 || rc=$?
[ "$rc" != "0" ] && { echo "[drill] pg_restore Code $rc:"; tail -3 /scratch/restore.log; }
psql -h /scratch/sock -U postgres -d "$db" -q -c 'ANALYZE;' >/dev/null 2>&1
rows=$(psql -h /scratch/sock -U postgres -d "$db" -tA \
-c 'select coalesce(sum(n_live_tup),0) from pg_stat_user_tables;')
echo "[drill] ${db}: ${rows} Zeilen"
# Der Exitcode von pg_restore ist kein Beweis (es warnt gern) -
# gezaehlte Zeilen sind einer.
if [ "$rows" -le 0 ]; then
echo "[drill] FEHLER: ${db} ist nach dem Restore LEER - Sicherung unbrauchbar!"
exit 1
fi
done
done
echo "[drill] OK - alle Sicherungen liessen sich zurueckspielen"
volumeMounts:
- name: ssh-key
mountPath: /secrets/ssh
readOnly: true
- name: known-hosts
mountPath: /secrets/known-hosts
readOnly: true
- name: scratch
mountPath: /scratch
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
volumes:
- name: ssh-key
secret:
secretName: synapse-backup-credentials
defaultMode: 0400
items:
- key: ssh-private-key
path: ssh-private-key
- name: known-hosts
configMap:
name: synapse-backup-known-hosts
- name: scratch
emptyDir:
sizeLimit: 4Gi
@@ -0,0 +1,23 @@
apiVersion: v1
kind: Secret
metadata:
name: synapse-backup-credentials
namespace: matrix
stringData:
borg-passphrase: ENC[AES256_GCM,data:RRXPwr4UGX30IdozM53abN7ZYztTO3Y1/63dtTh0JSZmU8i8l/ATb4gc3lc=,iv:rIyUr+lOUjo9J53OKZ5ZDmp3d8Nrb9PP2JDK2oCutYU=,tag:MqUmoYgCA03WJQy+RQi04w==,type:str]
ssh-private-key: ENC[AES256_GCM,data:320B/lSq7DljCrXZ0BluGv8gLIzYF3KL6VQnR6CM/Vzuf/6qbbZ7lMzrS96XfoubU95OxDeJuOsuNZBQUZNyUGtt4QjQmnl8XHUJzl48xLqh83HLFPtQm2uqU072lscf5OTT7I+JzD2BGks3OIowhrg1q0MdVQdfd4Rhdz8Jphjb+WxvstzNmEX5gxQ9mnBVPmj4DS6ikdXnpe+VDvaCJaVkzD5KgwRmqPy0qbFs9WXziQSo3am5fPeHbwhV3UlRhlok9WrDI40a1T1S5DBhgNhwShq1jAxjr9onuq92REymxAV50oLzsw9ivnH0uimw+3PcplRG1v2xxJ/pimWTCjE17bO7OZ8TyzzwyZ7QA02vSpOMNUdUwVU3N6pSdYQpdTETuVBTVqVc+GKC18Z9fWh2rdwX5eUDTwp0bbDDvPEHdANNxMg3VYD3gwpCgy6/wVnjrO+pVMs8K1CqFn4/H7azhzzeEPkrz27ZBxjlnqvDODsm9tlklr6X4jZDZmTaEmiH+WXuc+1qbNxvsrqtSFfa3CfFGxM1nBOh,iv:sKGsTLsxdQYVUvw7CEARL3YNInSd9LPbFp5Ci5CTgIw=,tag:O1mjNZUsGEqV4uu+LlgD/g==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3ZUdtN3hyNHlyMkIrbTVS
aHQ4OGNUWnA4eTFUVkx4UVp5VWlnMDgxTEU0CkU3M2dMYWgyKytlRVFOVWptZEd2
NXlIY0JCd24xcGFzaGpIeks0R2U0U3MKLS0tIHlxYVZ1ZTJsRXNaZ25sVzZtSnp0
SEhzQ0tUYzZTRXcwMVNwbG85SHpyb0UKOn3nxy6Y7yQkGargXQ9z6O36vUWW4qJZ
D/GbFGmoRi94EtVFdmTGALhjy2D4J9QXy6gHsTapvKyMxF8NEtk+FQ==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-07-28T18:20:58Z"
mac: ENC[AES256_GCM,data:Rur32fQdCyM3nr/X+KeSgmPYEi4nKyh8lqTuSW3TPBVDjwTWMDp1I1ZPPyy5syeW6RHbKputFUzBWVnuQmVrfbZaQ6DBBI5kP9InspUAVUjXDRk9XqiWtdg/wYaTMMJ4Nxv/zdwkh6uJQSG2JHQBWce4NZc2hoPokLR0CjcWiZk=,iv:eWTUj48EFjjtuIIuErMltEdDfabLZeolkpInMYtVP5Q=,tag:6DPxkPqLt0ihJ80WnTzHeA==,type:str]
version: 3.13.3
+96
View File
@@ -0,0 +1,96 @@
# Nightly Borg backup of the shared Postgres instance (synapse + MAS databases) and the
# Synapse media_store PVC to a Hetzner Storage Box (issues #6 + #15). See
# apps/production/synapse-backup-secret.yaml for the SSH key + Borg repo passphrase, and
# apps/authentik/authentik-backup.yaml for the equivalent authentik-side job.
apiVersion: v1
kind: ConfigMap
metadata:
name: synapse-backup-known-hosts
namespace: matrix
data:
# Pinned via `ssh-keyscan -p 23 u641795.your-storagebox.de` (2026-07-28) rather than
# trusting the host key on first connect in an unattended job.
known_hosts: |
[u641795.your-storagebox.de]:23 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIICf9svRenC/PLKIL9nk6K/pxQgoiFC41wTNvoIncOxs
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: synapse-backup
namespace: matrix
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
app.kubernetes.io/name: synapse-backup
app.kubernetes.io/component: backup
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: rohana.axion1337.de/sorb/axion-backup:v2
env:
- name: BORG_REPO
value: "ssh://u641795@u641795.your-storagebox.de:23/./synapse-backup"
- name: BORG_PASSPHRASE
valueFrom:
secretKeyRef:
name: synapse-backup-credentials
key: borg-passphrase
- name: SSH_PRIVATE_KEY_FILE
value: /secrets/ssh/ssh-private-key
- name: SSH_KNOWN_HOSTS_FILE
value: /secrets/known-hosts/known_hosts
- name: DB_HOSTS
value: "synapse:matrix-stack-postgres matrixauthenticationservice:matrix-stack-postgres"
- name: PGUSER
value: postgres
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: matrix-stack-generated
key: POSTGRES_ADMIN_PASSWORD
- name: MEDIA_PATH
value: /media/media_store
volumeMounts:
- name: ssh-key
mountPath: /secrets/ssh
readOnly: true
- name: known-hosts
mountPath: /secrets/known-hosts
readOnly: true
- name: media
mountPath: /media
readOnly: true
- name: scratch
mountPath: /scratch
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
volumes:
- name: ssh-key
secret:
secretName: synapse-backup-credentials
items:
- key: ssh-private-key
path: ssh-private-key
mode: 0400
- name: known-hosts
configMap:
name: synapse-backup-known-hosts
- name: media
persistentVolumeClaim:
claimName: matrix-stack-synapse-media
readOnly: true
- name: scratch
emptyDir: {}
+31
View File
@@ -0,0 +1,31 @@
apiVersion: v1
kind: Secret
metadata:
name: synapse-turn-secret
namespace: matrix
stringData:
values.yaml: ENC[AES256_GCM,data:jGoBDvjGymp4qEVEB6hzgRJk1ZUVB+jNxfhqpl7tG7UQmZGskix43rMJIOdDZihBTOUHP4emhlOzTxqx3SCAFCb8Ie78xA+DfjuLnQ6PQ2jqN48+yc7QE2xbNr5eWlYYKzl1u/z0kCQT723ptA+tQzYCzUq5U624HHxki0firgoSJmaF9S/vbwXHLPqtnviYX48Eim0suwMnavWdWGd1X3yvqUoZnWKz3eDQrH3cp4fQAdnRosveIcWwz+1na67Zshczq7hhlNp/WZ95guAOiqlLxGMPwJQJou24UYynwQTtDle5IzCxOww+sCNuQtPp29NE1gZ5pxIM7Ys6Ul7UxtPAww6OCVbkYYGmQxIq0cY4CrFrgLDJYQ2JJPIexc1ooeIDcYiNYts/2TCAj9ck+Vd+6xN1NEYwm0ZTLL9naLJJ1PEzRkfy3bCOTfKWCRNwLzn/3gsRgr+hrf5dJ6Av6VbM0Ae9xhTEQ4d64C8YRjb4G9BnDRGuaHu+5DYAqiFUEMm6cM4IV3E=,iv:ZRGQREujdrDCNj1OcgV7HAjZREArdnodnD2J5BxkGQo=,tag:2FOO9/rv4IteyulLnsR/YA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuK2xIT3M0cGtVOHdKYklF
OHl1VHFRTkVSaHVQNUUyRlhZUjRWM3JUN0hBCnpUS2YzdG1mSjlRaEVvTHdKVkR4
L1hrR05IRUdqdGp2aU95aWxRRXdsQWcKLS0tICtLbnFsK3Zza053VXFWdVErRy92
WUUzZTBIRzUyWnp5a1ZScUVqb0NyencKvnFyJCR6j1/aH4gJvFmLPNlk5XpC08wF
mTmL981uGfz4NULc+O3sDkonJ827glpefgWPgPW2HmKT88d4A9vyJw==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVY0NXa1B5c2RzbzRReU1h
RHBSbk9aK3d6VjRWZ1hzL3FDZFZJZGFqTVQ0Ck9ib0p3bkR6cU8yc0VFNjEzSFFi
T3dWY1luQW1VZ1NjZFNoZFFLSCsvelkKLS0tIHIxV0d6TWhpSEc3d3c0L1VvOWxk
eDhTM2pDbTNXZXlWRVYxR2tPQU5iLzgKrLLLSBU/g5ebeRNi7hWYbcuJ/2JOfiUn
0DBnzMVJPBfqq/u8THiRYaMajx3k4D9+FN7qc5nBgTd85iGBo+OowA==
-----END AGE ENCRYPTED FILE-----
recipient: age1x4jjwc8nuttwr8us924pvdc6dll5npkc6c8f4zf2hx5d2qu75dtqx0fm0d
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-08-01T02:00:01Z"
mac: ENC[AES256_GCM,data:zk0ivb9asZLHUg819tR8GV5R5ViSiJId0T1o2GqYWjc/AcNK1pBKwIJ+S3TuB0fLz3qaFXUBfzcc+CfztuTLcRzAfu3mF8Hv0boZTe0lGl5XugmozQWjoLjSw+roWZHlXd5CfYbDZsHSO70231NyPlpuUkVr2fqAFZgTEev2A9Y=,iv:IkRYK6PLE74i+Kq5fop4ddS+Q9KW8uNVfABK81uFyIw=,tag:kanZfzBXEYodnQtwWdZ85g==,type:str]
version: 3.13.3
@@ -0,0 +1,23 @@
apiVersion: v1
kind: Secret
metadata:
name: turn-secret-rotation-credentials
namespace: matrix
stringData:
age-key.txt: ENC[AES256_GCM,data:4LAs9LLFo38UMHXCo4lun9RHxGnDyp7GWlaNdIqqkSL9lNv7+ILdlc03CxFVobCYxK65xMOn1xdEty+887JBMlawST04am/5MkAnUivKwXCw8OHmbZhCwKHFqSYH/NsgVNf+btZKIIny8XPVQAPj/vQIi+Ity+BQyPkEZ1WUcsqjDoaK9IFhQTePJtHgWivhOY2WpUt/TP7vTfub4TOrgVpzNd9LIpBkwq+zhpVXKwnYUWOuLMXlPe08kazy,iv:9A24HbTl24slj+qTCfyI01+dGqRFVPDUA0wp2kSUHpc=,tag:iQxU0dWD4Noo1m0HXhV6vQ==,type:str]
gitea-token: ENC[AES256_GCM,data:cmZ1GCrqRYLtLn+cRVZCrO7UcCIavlQLJPt2PRMtBbgLdhDVTElUKA==,iv:9v29GXHRtDlrL3PoRCdOqYpBepZrX04+6UjoywRZX0E=,tag:ZvYXBOm9qgB1XA5lkV4LsQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5S3BtTHNLVGVVM2ZpTlZS
Y3pGMS9CSkNsdUpPbWtkSTRHK0p6U2lwdEJnClVWdXp4SllyM1hvbTZyTU40SDc2
QlVtMDduZWpaVENiYnhMNlFXd01QblEKLS0tIGNzTGRZcmoyaFltUHRDSHBPZE1N
OCszUkl1VjQ5V3F2cVI4dXJFcER5YXcK+2Eh1JNLuMiCnpQ3cL/I7XTykkIZ3tqp
O3c9UwYs1FAZWlMgElTBTqsmut1ShduIYfDFRKGeS0UxPEM4U+tIGw==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-07-28T19:21:28Z"
mac: ENC[AES256_GCM,data:vx6Bs/L0NXKUvvQdu6aYtuur/CYPkIBZzvFLqTyd08Errw0dGMrg73oHQ/imxpe42HgnO2mGwxNdEx2jYYbtc3RBWHE/yPH5m8y/XLoSL3fauzbkGsDwMSWzKiZXyIuGh7SxuB+CFY9qqFMK+dap1Ofno7a1/Gr1qibVDqscwxw=,iv:9AUQQuTCja09OohzVw73URMHE8xCW7iLLtBg7GSDcPA=,tag:g1ibnBIGqokvs2IEVOYq6Q==,type:str]
version: 3.13.3
+70
View File
@@ -0,0 +1,70 @@
# Automated TURN shared-secret rotation (Issue #38). Generates a new secret, re-encrypts
# apps/production/coturn-secret.yaml and synapse-turn-secret.yaml using a dedicated,
# narrowly-scoped age key (see turn-secret-rotation-secret.yaml - it can only decrypt these
# two files, not the repo's master sops-age key), bumps the checksum/rotated-at annotations
# so a merge restarts both consumers automatically, and opens a Pull Request rather than
# pushing straight to main - a human reviews and merges it.
apiVersion: batch/v1
kind: CronJob
metadata:
name: turn-secret-rotation
namespace: matrix
spec:
schedule: "0 4 1 * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app.kubernetes.io/name: turn-secret-rotation
app.kubernetes.io/component: rotation
spec:
restartPolicy: OnFailure
# Public-internet reachability to the Gitea host has been flaky (see Issue #41);
# both servers share a private Hetzner network. hostAliases (unlike the node-level
# /etc/hosts fix used for image pulls) is actually honored by in-pod processes.
hostAliases:
- ip: "10.0.0.3"
hostnames:
- "rohana.axion1337.de"
containers:
- name: rotate
image: rohana.axion1337.de/sorb/axion-secret-rotation:v1
env:
- name: GITEA_HOST
value: "rohana.axion1337.de"
- name: GITEA_REPO
value: "sorb/axion1337.chat-gitops"
- name: GITEA_TOKEN
valueFrom:
secretKeyRef:
name: turn-secret-rotation-credentials
key: gitea-token
- name: SOPS_AGE_KEY_FILE
value: /secrets/age/age-key.txt
- name: GIT_AUTHOR_NAME
value: "turn-secret-rotation"
- name: GIT_AUTHOR_EMAIL
value: "turn-secret-rotation@axion1337.chat"
volumeMounts:
- name: age-key
mountPath: /secrets/age
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
volumes:
- name: age-key
secret:
secretName: turn-secret-rotation-credentials
items:
- key: age-key.txt
path: age-key.txt
mode: 0400
+33
View File
@@ -0,0 +1,33 @@
# Öffentlicher Zugang zum Wiki.js unter wiki.axion1337.chat (ADR-0014, #0048).
# Muster wie apps/authentik/{certificate,ingress}.yaml. DNS-A-Record auf
# 49.13.132.245 ist von sorb angelegt. Login läuft nativ über Authentik-OIDC
# (kein Forward-Auth) — siehe Guide 10 / #0049.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wiki-axion1337-chat-cert
namespace: matrix
spec:
secretName: wiki-axion1337-chat-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- wiki.axion1337.chat
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: wikijs
namespace: matrix
spec:
entryPoints:
- websecure
tls:
secretName: wiki-axion1337-chat-tls
routes:
- match: Host(`wiki.axion1337.chat`)
kind: Rule
services:
- name: wikijs
port: 3000
+23
View File
@@ -0,0 +1,23 @@
apiVersion: v1
data:
email: ENC[AES256_GCM,data:VXwOHfp9spepzaQ6HrXyeDTITxKWSiEdV3u9pA==,iv:KC6SzHMOELHlFIcM0xW8wXZ+lG61y3arFt8DZMTtm9g=,tag:C5KxglXJzfG86+J4O+6y6g==,type:str]
password: ENC[AES256_GCM,data:+r73YasvUGbuEYrcqR1WiVzXunnk7QzTnemu2wtzoQb9T30PA1+Ea/Lap9chSmmR,iv:UT1nDAdoi8jDRG0GN1Tr+ZJbDyHhV0X09HvLxkmOwxQ=,tag:i1+ogn8IjW33hbfKl4t5gQ==,type:str]
kind: Secret
metadata:
name: wikijs-admin-secret
namespace: matrix
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKMklFSTZyTTNuREExaW5h
ZXFoeS8yMFZvSjFpOXhjQ3JpdmtqNVh4VDN3Ci9jNnd5dFpUdGtlVFFubkRmUjRD
WnBEaVVSbEtsakJQMzBJOFI5bDVSR1EKLS0tIEo2NjU0SldVMGg0LzZBaXR2OXFy
MzZTRURZYWZ6VDlaSTVramI5aGlSU0kKvY0+xfsUknIlMxTT4zjM40P0LK05Dp2S
ufEZU3+IamAdxVhdwaJmT8eEJvd/Q5off35D6OzylZPzdlsrqn5wGg==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-08-12T21:07:47Z"
mac: ENC[AES256_GCM,data:O/w/lIrlbyclnk6MiGcsg0OgMEjMoHwqYjKLCU53G+mlk+wecuL13MeMTbHW1yf03/dieCcVzYwSspRZyQGmZevO4/QCefN5mVWlzxLPSLmRmOeEA7IJxGqiLXn/GliVfdpvSlifRM0CxfrCWGZ1iBWQqxPbJD9tZRhmaytfHXY=,iv:2adSCqQHm9KBCOHAH0EJ0Un42mx42CqIgELfe7NgP4I=,tag:no+CjJ0Fs2y58sava9Ieyw==,type:str]
version: 3.13.3
+480
View File
@@ -0,0 +1,480 @@
#!/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 -> hideLocal
-> Theming/Branding -> Navigation (Custom-Sidebar) -> HTML-Rendering (allowHTML)
-> Git-Storage.
Idempotent: jeder Schritt prüft erst den Ist-Zustand. Seiten-Inhalt (Startseite,
Anwender-/Betriebsseiten) liegt in git-storage, nicht in diesem Job.
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/<key>/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"]
# App-/Inhalts-UI-CSS (#0050 + UX-Fixes). Gilt NICHT für die Login-Seite (die rendert
# Wiki.js ohne Custom-CSS).
# - Content-Links: Akzent + Unterstreichung, damit klar als Link erkennbar. Die frühere
# breite .primary--text-Einfärbung ließ Nicht-Links wie Links aussehen -> raus.
# - Startseiten-Kacheln + Schnellzugriff-Buttons (die Startseite nutzt HTML, s. allowHTML).
ACCENT_CSS = (
".v-application .contents a{color:#2b6cb0;text-decoration:underline}"
".theme--dark.v-application .contents a{color:#63b3ed}"
".v-application .contents a.toc-anchor{color:inherit!important;text-decoration:none!important}"
".wiki-hero{background:rgba(255,255,255,.05);border-radius:10px;padding:26px 24px;margin:8px 0 20px}"
".wiki-hero h1{border:none!important;margin:0 0 6px}.wiki-hero p{margin:0;color:#9ca3af}"
".wiki-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin:10px 0 26px}"
".wiki-card{display:flex!important;flex-direction:column;gap:6px;padding:20px;border-radius:10px;"
"background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.10);text-decoration:none!important}"
".wiki-card:hover{background:rgba(99,179,237,.12);border-color:#63b3ed}"
".wiki-card .wc-t{font-size:1.15rem;font-weight:600;color:#e5e7eb}"
".wiki-card .wc-d{color:#9ca3af;font-size:.9rem}"
".wiki-btns{display:flex;flex-wrap:wrap;gap:10px;margin:6px 0 22px}"
".wiki-btn{display:inline-block;padding:9px 16px;border-radius:6px;background:#2b6cb0;color:#fff!important;"
"text-decoration:none!important;font-size:.92rem;font-weight:500}"
".wiki-btn:hover{background:#63b3ed}"
)
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, auto_enroll: list | None = None):
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,
# autoEnrollGroups: jeder Authentik-Login landet in wiki-anwender
# (#0049: "Betrieb = Admin", Anwender lesen /anwender + Startseite).
# Ohne das legt selfRegistration zwar ein Konto an, es bekommt aber
# KEINE Gruppe - und weil Guests unten alle Rechte entzogen bekommt,
# sieht der Nutzer dann gar nichts und erfaehrt auch nicht warum
# (#0103, real passiert). Admins bleiben Handarbeit in Authentik:
# Mitgliedschaft in "authentik Admins" wird ueber den groups-Claim
# gemappt und ueberschreibt diese Grundausstattung nicht.
"domainWhitelist": [], "autoEnrollGroups": auto_enroll or [],
"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()]
# Die Gruppe existiert hier sicher: main() legt sie vor ensure_oidc an.
anwender = group_id(jwt, "wiki-anwender")
if anwender is None:
sys.exit("ABBRUCH: Gruppe 'wiki-anwender' nicht gefunden — ohne sie liefe "
"jeder neue Login wieder ins Leere (#0103).")
_set_strategies(jwt, oidc_config, [anwender])
log(f"OIDC-Strategy gesetzt (local bleibt aktiv als Break-Glass; "
f"Auto-Enrollment in wiki-anwender, id {anwender})")
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": "right",
"c": ACCENT_CSS, "h": tc["injectHead"], "b": tc["injectBody"]})
# Site-Titel (oben links / Browser-Tab) aus der Deployment-Variable — nicht mehr
# das hartkodierte "Wiki.js". Reproduzierbar über WIKI_SITE_TITLE.
title = os.environ.get("WIKI_SITE_TITLE", "Wiki").strip() or "Wiki"
gql('mutation($bg:String!,$logo:String!,$ttl:String!){site{updateConfig('
'authLoginBgUrl:$bg,logoUrl:$logo,title:$ttl){responseResult{succeeded message}}}}',
jwt, {"bg": bg, "logo": logo, "ttl": title})
log(f"Theming gesetzt (Titel '{title}', Dark-Default, Logo + Hintergrund aus Mounts)")
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 set_navigation(jwt: str):
# Eigenes Sidebar-Menü (STATIC) statt des Browse-Baums: Home + Anwender + Betrieb.
# Betrieb-Einträge nur für Admin-Gruppen sichtbar (1 Administrators, 3 authentik
# Admins); die Seiten sind ohnehin per checkAccess für Anwender gesperrt.
admin = [1, 3]
def lnk(i, label, icon, path, g=None):
# Das Default-Theme rendert den Sidebar-Link wortwörtlich als href=target (ohne
# targetType-/Slash-Behandlung). Page-Targets MUESSEN daher absolut sein, sonst
# loesen sie relativ auf (von /betrieb/x aus -> /betrieb/<target> -> 404). Wiki.js'
# eigener Editor speichert /<locale>/<path>; hier /<path> (namespacing=false), passend
# zu allen Inhalts-Links.
return {"id": i, "kind": "link", "label": label, "icon": icon,
"targetType": "page", "target": "/" + path,
"visibilityMode": "restricted" if g else "all", "visibilityGroups": g or []}
def hdr(i, label, g=None):
return {"id": i, "kind": "header", "label": label, "icon": "",
"targetType": "external", "target": "",
"visibilityMode": "restricted" if g else "all", "visibilityGroups": g or []}
items = [
{"id": "home", "kind": "link", "label": "Startseite", "icon": "mdi-home",
"targetType": "home", "target": "/", "visibilityMode": "all", "visibilityGroups": []},
hdr("hanw", "Anwenderdokumentation"),
lnk("a1", "Registrierung", "mdi-account-plus", "anwender/registrierung"),
lnk("a2", "Anmelden & Konto", "mdi-login", "anwender/anmelden-und-konto"),
lnk("a3", "Erste Schritte", "mdi-rocket-launch-outline", "anwender/erste-schritte"),
lnk("a4", "Nachrichten", "mdi-message-text", "anwender/nachrichten"),
lnk("a5", "Anrufe & Screensharing", "mdi-phone", "anwender/anrufe"),
lnk("a6", "Verschlüsselung", "mdi-lock", "anwender/verschluesselung"),
lnk("a7", "Benachrichtigungen", "mdi-bell", "anwender/benachrichtigungen"),
lnk("a8", "ThreadNet Desktop", "mdi-desktop-classic", "anwender/threadnet-desktop-setup"),
hdr("hbet", "Betriebsdokumentation", admin),
lnk("b0", "Einführung & Architektur", "mdi-sitemap", "betrieb/einfuehrung", admin),
lnk("b1", "Installation", "mdi-download", "betrieb/installation", admin),
lnk("b2", "ConfigMap-Sync", "mdi-sync", "betrieb/configmap-sync", admin),
lnk("b3", "Authentik OIDC", "mdi-shield-account", "betrieb/authentik-oidc", admin),
lnk("b4", "Element / ThreadNet", "mdi-palette", "betrieb/element-customization", admin),
lnk("b5", "Element Call & MatrixRTC", "mdi-video", "betrieb/element-call", admin),
lnk("b6", "TURN Server", "mdi-server-network", "betrieb/turn-server", admin),
lnk("b7", "Moderation & Scanning", "mdi-shield-check", "betrieb/moderation-content-scanning", admin),
lnk("b8", "Monitoring", "mdi-chart-line", "betrieb/monitoring", admin),
lnk("b9", "Backups", "mdi-backup-restore", "betrieb/backups", admin),
lnk("b10", "Host-Wartung", "mdi-wrench", "betrieb/host-maintenance-notifications", admin),
lnk("b11", "Room Policies", "mdi-gavel", "betrieb/room-policies", admin),
lnk("b12", "Upgrades", "mdi-update", "betrieb/upgrades", admin),
lnk("b13", "Sicherheit", "mdi-shield-lock", "betrieb/sicherheit", admin),
]
gql('mutation($m:NavigationMode!){navigation{updateConfig(mode:$m){responseResult{succeeded}}}}',
jwt, {"m": "STATIC"})
# Nav-Baum unter der Standard-Locale 'de' (die Seiten liegen nach der Migration unter
# de; getTree wird mit der Seiten-Locale abgefragt — ein en-Baum bliebe unsichtbar).
gql('mutation($t:[NavigationTreeInput]!){navigation{updateTree(tree:$t)'
'{responseResult{succeeded message}}}}', jwt, {"t": [{"locale": "de", "items": items}]})
log("Navigation gesetzt (Custom-Sidebar; Betrieb nur für Admins)")
def ensure_html_rendering(jwt: str):
# Die Startseite nutzt HTML-Kacheln -> markdownCore.allowHTML muss an sein. Wiki.js'
# updateRenderers-Resolver liest jeden Wert via _.get(JSON.parse(value),'v',null),
# also {"v":…}-kodiert; die übrigen Renderer-Werte müssen mitgesendet werden, sonst
# werden sie genullt.
# ⚠️ Wiki.js lädt die Renderer-Config beim START. Auf einer FRISCHEN Instanz greift
# allowHTML erst nach einem Wiki.js-Neustart; die Startseite rendert dann beim
# nächsten git-Sync korrekt (sonst erscheint sie kurz als HTML-Text). Idempotent.
rs = gql('{rendering{renderers{isEnabled key config{key value}}}}', jwt)["rendering"]["renderers"]
def actual(v):
try:
p = json.loads(v)
return p["value"] if isinstance(p, dict) and "value" in p else p
except Exception:
return v
out = []
for r in rs:
cfg = []
for c in (r["config"] or []):
a = actual(c["value"])
if r["key"] == "markdownCore" and c["key"] == "allowHTML":
a = True
cfg.append({"key": c["key"], "value": json.dumps({"v": a})})
out.append({"isEnabled": r["isEnabled"], "key": r["key"], "config": cfg})
gql('mutation($r:[RendererInput]){rendering{updateRenderers(renderers:$r)'
'{responseResult{succeeded message}}}}', jwt, {"r": out})
log("HTML-Rendering aktiviert (markdownCore.allowHTML) — Neustart nötig auf frischer Instanz")
def ensure_locale(jwt: str):
# Deutsch als Standardsprache: die Inhalte sind deutsch, hingen aber an 'en'.
# updateLocale lädt live (setCurrentLocale + refreshNamespaces, KEIN Neustart);
# namespacing bleibt aus (eine Sprache, saubere /<pfad>-URLs), 'en' bleibt als
# Namespace registriert.
loc = gql('{localization{locales{code isInstalled} config{locale namespaces}}}',
jwt)["localization"]
installed = {l["code"] for l in loc["locales"] if l["isInstalled"]}
if "de" not in installed:
# de muss in der locales-Tabelle liegen (updateLocale liest dort isRTL). Auf
# frischen Instanzen ggf. herunterladen (braucht Egress) — weich, kein Hard-Fail.
try:
gql('mutation{localization{downloadLocale(locale:"de")'
'{responseResult{succeeded message}}}}', jwt)
log("Locale 'de' heruntergeladen")
except Exception as e: # noqa: BLE001
log(f"WARN: downloadLocale(de) fehlgeschlagen ({e}) — Locale-Umstellung übersprungen")
return
if loc["config"]["locale"] != "de":
ns = sorted(set((loc["config"].get("namespaces") or []) + ["de", "en"]))
gql('mutation($l:String!,$a:Boolean!,$n:Boolean!,$ns:[String]!){localization{'
'updateLocale(locale:$l,autoUpdate:$a,namespacing:$n,namespaces:$ns)'
'{responseResult{succeeded message}}}}',
jwt, {"l": "de", "a": True, "n": False, "ns": ns})
log("Standardsprache auf Deutsch gesetzt (de)")
# Inhalte en->de migrieren (patcht nur die pages-Tabelle; das ist auch Wiki.js'
# eigener Locale-Migrationsweg, kollisions-sicher via whereNotExists). Danach Tree
# + Suchindex neu bauen, aber NUR wenn wirklich migriert wurde — sonst teurer No-op
# bei jedem Job-Lauf. (git-storage-Frontmatter der Altdateien bleibt 'en' und heilt
# beim nächsten Bearbeiten; dormante Dateien werden nicht re-importiert.)
m = gql('mutation{pages{migrateToLocale(sourceLocale:"en",targetLocale:"de")'
'{responseResult{succeeded message} count}}}', jwt)["pages"]["migrateToLocale"]
if (m.get("count") or 0) > 0:
gql('mutation{pages{rebuildTree{responseResult{succeeded}}}}', jwt)
gql('mutation{search{rebuildIndex{responseResult{succeeded}}}}', jwt)
log(f"Inhalte nach de migriert ({m['count']} Seiten) + Tree/Suchindex neu gebaut")
else:
log("Locale-Migration: keine en-Seiten offen (bereits de)")
def ensure_timezones(jwt: str):
# Systemkonten auf Europe/Berlin statt Wiki.js-Default America/New_York: guest
# (id 2, treibt die anonyme/Standard-Anzeige) + der Admin. Menschliche OIDC-Nutzer
# setzen ihre TZ im Profil; NEUE Nutzer erben aber weiter den im Core hardcodierten
# New_York-Default (server/core/auth.js) — nur per Fork-Patch änderbar, nicht per
# Config. update patcht nur das timezone-Feld (kein Nulling, groups nur bei Array).
ids = {2} # guest
for u in gql("{users{list{id email}}}", jwt)["users"]["list"]:
if u["email"] == ADMIN_EMAIL:
ids.add(u["id"])
for uid in sorted(ids):
gql('mutation($id:Int!){users{update(id:$id,timezone:"Europe/Berlin")'
'{responseResult{succeeded message}}}}', jwt, {"id": uid})
log(f"Zeitzone Europe/Berlin für Systemkonten gesetzt (ids {sorted(ids)})")
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, match: str = "START"):
return {"id": rid, "deny": deny, "match": match, "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"], "")])
# wiki-anwender: nur den Anwender-Bereich lesen + die Startseite (sonst wäre "/"
# für sie gesperrt). betrieb/* matcht keine Regel -> Default-Deny (Abschottung).
ensure_group(jwt, "wiki-anwender", READER_PERMS, [
rule("anw", False, READER_PERMS, "anwender"),
rule("anwhome", False, READER_PERMS, "home", match="EXACT"),
])
# 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_locale(jwt) # Deutsch als Default + Inhalte en->de migrieren (vor der Nav)
set_navigation(jwt)
ensure_html_rendering(jwt)
ensure_git_storage(jwt)
ensure_timezones(jwt) # Systemkonten auf Europe/Berlin
log("fertig — Wiki.js konfiguriert")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
# 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
# Site-Titel (oben links + Browser-Tab), konfigurierbar pro Deployment.
- name: WIKI_SITE_TITLE
value: 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
# Git-Storage (ADR-0015): Inhalt nach Gitea syncen. Klartext-Koordinaten;
# nur der PAT liegt im SOPS-Secret wikijs-git-secret. optional=true, damit
# der Job auch läuft, bevor das Secret existiert (Skript überspringt dann).
- name: GIT_STORAGE_REPO
value: https://rohana.axion1337.de/sorb/ThreadNetWiki.git
- name: GIT_STORAGE_USER
value: sorb
- name: GIT_STORAGE_BRANCH
value: main
- name: GIT_STORAGE_EMAIL
value: wiki@axion1337.chat
- name: GIT_STORAGE_NAME
value: ThreadNet Wiki
- name: GIT_STORAGE_TOKEN
valueFrom:
secretKeyRef:
name: wikijs-git-secret
key: token
optional: true
volumeMounts:
- name: script
mountPath: /script
volumes:
- name: script
configMap:
name: wikijs-config-script
+22
View File
@@ -0,0 +1,22 @@
apiVersion: v1
data:
token: ENC[AES256_GCM,data:Z+oD7NDgHTTuMyNLFY+2PhZmTJzN4eIAGgmCx2TnI73BQ2v40Um/ppioLY4kyow56lfj23jpI6w=,iv:/Y7Oti9e7+Se4DjxI1nT4J0f+l73PKNbgbeSiyIpdU8=,tag:ilW/M/uSTn8qOUcUfOhtwA==,type:str]
kind: Secret
metadata:
name: wikijs-git-secret
namespace: matrix
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4c3pFV3gxeEgrVjkzVnZ3
TkZHbThJV0dqY3BXanhtMExzUTV3aTdXTlVVCjQxZjN2cjRiMjQzTG1XM3JyVzZt
Si9GL1ZEVFArd2FxR3ltaDJhS21GeFkKLS0tIHhKVUtKaUlFOGRkOGJNUkR0Ymwy
dzREc0lNMlowMXdNa1I3ODhsN2k4WUkKFRTpU/cQq0+EcIldJn3/77P6HYaxH0uK
Xd6CT+WdX83vcmwX4d1Brh6y3GEX+cKeNAaxUL4raZdkjCOz0n8bnQ==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-08-13T09:46:49Z"
mac: ENC[AES256_GCM,data:STpYfsCDwZA1OIZLfTrO3Ww6Nuo8h/lIufrnQj8yeeuYcT1vEev0Tr/c2+8fNRW4yLEfRkqNMhP4p6akh9G2BChQN4FnwuUqb9yA7s+una83IXmn4UOCvwchxhY2c95lLKMNr45f3fUx6bw4tlENRor3ZrF3+jvvbX7b6c1XlC8=,iv:LVZdHBWSbwUgc4/JQSbw6twIVsa6QdSzAW7LnQn81I0=,tag:tTfFxm1URN1U1bbOroKHUA==,type:str]
version: 3.13.3
+23
View File
@@ -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
@@ -0,0 +1,82 @@
# Nightly Borg backup der Wiki.js-Postgres-DB auf die Hetzner Storage Box (#0048).
# Der Inhalt liegt bereits versioniert in git (git-storage -> Gitea -> git.lab); dieses
# Backup sichert den Laufzeit-Zustand, den git NICHT hält: Kommentare, lokale Konten,
# OIDC-Nutzer und den Suchindex. Muster: apps/authentik/authentik-backup.yaml (DB-only).
# Wiederverwendet die synapse-backup-Credentials (SSH-Key + Borg-Passphrase) und das
# -known-hosts (gleicher Namespace, gleiche Storage Box), aber einen EIGENEN Borg-Repo-
# Pfad (wikijs-backup) und einen versetzten Zeitplan.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wikijs-backup
namespace: matrix
spec:
schedule: "30 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
app.kubernetes.io/name: wikijs-backup
app.kubernetes.io/component: backup
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: rohana.axion1337.de/sorb/axion-backup:v2
env:
- name: BORG_REPO
value: "ssh://u641795@u641795.your-storagebox.de:23/./wikijs-backup"
- name: BORG_PASSPHRASE
valueFrom:
secretKeyRef:
name: synapse-backup-credentials
key: borg-passphrase
- name: SSH_PRIVATE_KEY_FILE
value: /secrets/ssh/ssh-private-key
- name: SSH_KNOWN_HOSTS_FILE
value: /secrets/known-hosts/known_hosts
- name: DB_HOSTS
value: "wiki:wikijs-postgres"
- name: PGUSER
valueFrom:
secretKeyRef:
name: wikijs-postgres-secret
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: wikijs-postgres-secret
key: password
volumeMounts:
- name: ssh-key
mountPath: /secrets/ssh
readOnly: true
- name: known-hosts
mountPath: /secrets/known-hosts
readOnly: true
- name: scratch
mountPath: /scratch
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
volumes:
- name: ssh-key
secret:
secretName: synapse-backup-credentials
items:
- key: ssh-private-key
path: ssh-private-key
mode: 0400
- name: known-hosts
configMap:
name: synapse-backup-known-hosts
- name: scratch
emptyDir: {}
@@ -0,0 +1,23 @@
apiVersion: v1
data:
password: ENC[AES256_GCM,data:ALT8e6Z6nZXHWkB6w3ZtkW7z14VAN0LpFITlCQBiJ9Up0O0dVhikfseCygo=,iv:Pn6U3t5QCqnYZA2TAk/fQBnE7FITTVoNzyCzMA/Wo+w=,tag:KAHpwDDRN9bIzMkWFBooqQ==,type:str]
username: ENC[AES256_GCM,data:KJQbdaJXroo=,iv:0ZVry78Bg9WRW0+vR7Opib9McGC5pyHtaxXExyh9dAk=,tag:PJvI4tigyIDZc0ASLEvUFA==,type:str]
kind: Secret
metadata:
name: wikijs-postgres-secret
namespace: matrix
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDY2xSYVl1R3M5SVI4UnR1
cFpZcXBLL2thdDdWRmtSa3kxM3FkRWIwRW1ZCk8xa25GbzVDQjlFNk0rcmUwcldJ
Z0JNVWhXQTY3VWlDYjFPQkFQM1FFRzgKLS0tIDkzME02K3hYTUFWNGpLY3N3emcr
OXhrd2pUSnpTU2ZUWHV2MnMzdUFVVlUKyrvbkRz4sM5FjdHbP0vdkqYlQZxr33c1
HDScKVKJ++RBXpP4+x7B6rjSiMsV0I8eT678IO3wz/qGugvrTTZt0w==
-----END AGE ENCRYPTED FILE-----
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-08-12T19:16:30Z"
mac: ENC[AES256_GCM,data:3uz4TmPnSwNK3vmBmRzGtwl2qMxPMQDWEqxpwQ0AbTOpyCiLmhqh1/eIvH1qOQoV5RQMkfcGa0XF2dxb7Wr4S52m8E19zyOmw2HNHvUj9gt2IUGHBUm5nZ2Qd0fO38VTdRX+Eyv7y/brbz1+guXMOTAwAbEHh51oV7rc7cyRCQo=,iv:8Kl7qOyfF1AlEMIFTaBM7nXZDE6RhsPJjj9QbzIJ8ns=,tag:BtvI0TpoY339XfXcknTVDw==,type:str]
version: 3.13.3
+79
View File
@@ -0,0 +1,79 @@
# Dedizierter Postgres für Wiki.js (ADR-0014, #0048). Bewusst eigenständig statt in
# den Synapse-/Authentik-DBs, damit das Wiki als Suite-Baustein reproduzierbar bleibt.
# git ist die Inhalts-Quelle (Wiki.js Git-Storage), diese DB ist Laufzeit-Index/Cache.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wikijs-postgres-data
namespace: matrix
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: wikijs-postgres
namespace: matrix
spec:
replicas: 1
strategy:
type: Recreate # RWO-Volume: kein RollingUpdate mit zwei Pods
selector:
matchLabels:
app.kubernetes.io/name: wikijs-postgres
template:
metadata:
labels:
app.kubernetes.io/name: wikijs-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_DB
value: wiki
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: wikijs-postgres-secret
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: wikijs-postgres-secret
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "wiki", "-d", "wiki"]
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: wikijs-postgres-data
---
apiVersion: v1
kind: Service
metadata:
name: wikijs-postgres
namespace: matrix
spec:
selector:
app.kubernetes.io/name: wikijs-postgres
ports:
- name: postgres
port: 5432
targetPort: postgres
+140
View File
@@ -0,0 +1,140 @@
# Wiki.js — Plattform-Wiki (ADR-0014, #0048). Öffentlich unter wiki.axion1337.chat
# (Ingress/Cert in wiki-ingress.yaml), Login nativ via Authentik-OIDC (#0049, Guide 10).
# Git-Storage (Inhalt in git) wird nach dem ersten Start in der Admin-UI eingerichtet.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wikijs-data
namespace: matrix
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: wikijs
namespace: matrix
spec:
replicas: 1
strategy:
type: Recreate # RWO-Volume (Git-Storage-Klon)
selector:
matchLabels:
app.kubernetes.io/name: wikijs
template:
metadata:
labels:
app.kubernetes.io/name: wikijs
spec:
containers:
- name: wikijs
image: ghcr.io/requarks/wiki:2.5
# Fork-Patch (ThreadNet): neue OIDC-Nutzer sollen Europe/Berlin statt des
# Wiki.js-Defaults America/New_York bekommen. Wiki.js legt SSO-Nutzer in
# processProfile (server/models/users.js) OHNE timezone an -> es greift der
# DB-Spalten-Default (New_York). Wir setzen timezone bei der Neuanlage explizit.
# Startup-Overlay statt Custom-Image (es gibt keine Wiki.js-Build-Pipeline):
# idempotent (grep-Guard), failt open (node startet auch wenn der sed nicht
# greift). localeCode kommt bereits aus WIKI.config.lang.code (= de).
# ⚠️ Bei Wiki.js-Upgrade den Anker "localeCode: WIKI.config.lang.code," prüfen.
command:
- sh
- -c
- >-
grep -q "timezone: 'Europe/Berlin'" server/models/users.js ||
sed -i "s#localeCode: WIKI.config.lang.code,#localeCode: WIKI.config.lang.code, timezone: 'Europe/Berlin',#" server/models/users.js;
exec node --no-deprecation server
ports:
- name: http
containerPort: 3000
env:
- name: DB_TYPE
value: postgres
- name: DB_HOST
value: wikijs-postgres
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: wiki
- name: DB_USER
valueFrom:
secretKeyRef:
name: wikijs-postgres-secret
key: username
- name: DB_PASS
valueFrom:
secretKeyRef:
name: wikijs-postgres-secret
key: password
volumeMounts:
- name: data
mountPath: /wiki/data
# Branding-Assets (Logo + Login-Hintergrund) als statische Dateien in den
# öffentlichen /_assets-Baum (express.static /wiki/assets). Dadurch ohne
# read:assets erreichbar (Guests bleiben gesperrt) und ohne externe URL —
# eine Quelle (ConfigMap platform-branding), Config zeigt auf /_assets/img/branding/.
- name: branding
mountPath: /wiki/assets/img/branding
readOnly: true
# Favicon (#0050): serve-favicon liest /wiki/assets/favicon.ico; der
# Browser-Tab nutzt zusätzlich die PNGs unter /_assets/favicons/. Einzelne
# Dateien per subPath überlagern, ohne die restlichen Default-Favicons zu verdecken.
- name: branding
mountPath: /wiki/assets/favicon.ico
subPath: favicon.ico
readOnly: true
- name: branding
mountPath: /wiki/assets/favicons/favicon-32x32.png
subPath: favicon-32x32.png
readOnly: true
- name: branding
mountPath: /wiki/assets/favicons/favicon-16x16.png
subPath: favicon-16x16.png
readOnly: true
- name: branding
mountPath: /wiki/assets/favicons/android-chrome-192x192.png
subPath: android-chrome-192x192.png
readOnly: true
- name: branding
mountPath: /wiki/assets/favicons/apple-touch-icon.png
subPath: apple-touch-icon.png
readOnly: true
- name: branding
mountPath: /wiki/assets/favicons/mstile-150x150.png
subPath: mstile-150x150.png
readOnly: true
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 25
periodSeconds: 10
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 60
periodSeconds: 20
volumes:
- name: data
persistentVolumeClaim:
claimName: wikijs-data
- name: branding
configMap:
name: platform-branding
---
apiVersion: v1
kind: Service
metadata:
name: wikijs
namespace: matrix
spec:
selector:
app.kubernetes.io/name: wikijs
ports:
- name: http
port: 3000
targetPort: http
+24
View File
@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIIBrzCCAVWgAwIBAgIQMZ1JRdPpesdAzJqMZOa9tDAKBggqhkjOPQQDAjA2MRUw
EwYDVQQKEwxhWGlvbkxhYnMgQ0ExHTAbBgNVBAMTFGFYaW9uTGFicyBDQSBSb290
IENBMB4XDTI1MTIwMjIyMDEyMVoXDTM1MTEzMDIyMDEyMVowNjEVMBMGA1UEChMM
YVhpb25MYWJzIENBMR0wGwYDVQQDExRhWGlvbkxhYnMgQ0EgUm9vdCBDQTBZMBMG
ByqGSM49AgEGCCqGSM49AwEHA0IABFuhwykAsSg2BWI3wqWHjNuL+5xlGjhHyiSt
6YmoSd1tJGa7WA5pdRRm2JZriA3IWuhdkZVTUl8nLduRJVTb5KSjRTBDMA4GA1Ud
DwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBRGPIQeqG1S
CyHYLwSQbe4jQ9RcZDAKBggqhkjOPQQDAgNIADBFAiEA1Z0Dxtj9hJOYkLJ8FLNT
96eatVP/OsKyaRk/MxReIwMCICrCeNSSOGUbiZ8Hd89jtlkCpWiLH1pCzZPUtYDX
R7gr
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIB2DCCAX6gAwIBAgIQLv6q8XwRjaYo8l0gB9NdyjAKBggqhkjOPQQDAjA2MRUw
EwYDVQQKEwxhWGlvbkxhYnMgQ0ExHTAbBgNVBAMTFGFYaW9uTGFicyBDQSBSb290
IENBMB4XDTI1MTIwMjIyMDEyMloXDTM1MTEzMDIyMDEyMlowPjEVMBMGA1UEChMM
YVhpb25MYWJzIENBMSUwIwYDVQQDExxhWGlvbkxhYnMgQ0EgSW50ZXJtZWRpYXRl
IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECj764yT9QNMsRjEfmHnuDI1o
11faTeMl5kcwZlnDetHF5O+Ey5d+kKuysqIOF1YX8bPt6o61aJYRmc4RxRDU/aNm
MGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYE
FGpRTHbW0eTgBMJep8w+Qyvy9oWzMB8GA1UdIwQYMBaAFEY8hB6obVILIdgvBJBt
7iND1FxkMAoGCCqGSM49BAMCA0gAMEUCIEAbqm/Y14dyM7h+I5en2l596eDDh3cS
2GwVO3CKuBY2AiEAkjPO/8M8PaFEaoncc/JDFxk9KR2P9IY24BvUA7YVsAU=
-----END CERTIFICATE-----
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable → Regular
+87 -196
View File
@@ -115,33 +115,53 @@ verankert. Was in dieser Session erledigt wurde:
- **Status**: NEXT
### 🟠 **NEXT 12 WEEKS HIGH**
1. **Authentik End-to-End Test**
- Test: Login flow Element → MAS → Authentik → Matrix User
- Test: Password reset
- Create: Test invite links
- Est. Time: 2 hours
1. **Authentik End-to-End Test** — erledigt als Teil von Issue #7 (Enrollment/Recovery/2FA,
2026-07-27), mit echten Test-Usern verifiziert. **Status**: COMPLETE
2. **Element Call Fork**
- Fork: element-hq/element-call
- Feature: Video/audio constraints parameters
- Integration: Synapse well-known config
- Est. Time: 23 days
2. **Element Call Fork** — erledigt, Closes Issue #8 (2026-07-28), siehe
`docs/deployment-guides/04-element-customization.md` Kapitel 4. **Status**: COMPLETE
3. **External PostgreSQL Migration**
3. **External PostgreSQL Migration** → [Issue #9](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/9)
- Decision: CloudNativePG vs. Hetzner Postgres
- Setup: HA + Replication
- Migration: Move data from ESS embedded Postgres
- Testing: Verify all services work
- Est. Time: 12 days
4. **NetworkPolicies Deployment**
- Create: Default-Deny for `matrix` namespace
- Create: Allow rules (Synapse↔Postgres, MAS↔Postgres, Ingress→Web, etc.)
- Test: Ensure no service breakage
- Est. Time: 1 day
4. **NetworkPolicies Deployment** — erledigt, Closes Issue #10 (2026-07-28), Default-Deny
für `matrix`+`authentik` Namespaces. **Status**: COMPLETE
---
## 🗓️ Session-Zusammenfassung 2026-07-30 bis 2026-08-01 (CI/CD-Umzug ins Lab)
1. **CI/CD komplett ins Homelab-GitLab migriert** (`git.lab`, nur im Lab auflösbar) —
nach vier realen Gitea-Actions/act-Bugs und einem verifizierten Webpack-OOM auf dem
3,7-GiB-CFGMON-Runner. ThreadNet-Web-Pipeline vollautomatisch grün (Web-Build,
Image-Push nach rohana, Desktop-Linux deb/tar.gz). Schlankes Trigger-Schema: Pushes
prüfen nur bei relevanten Pfaden, Artefakte entstehen bei `v*`-Tags.
(ThreadNet-Web#2 ✅ geschlossen)
2. **Repo-Topologie neu**: git.lab ist kanonisch für gitops, ThreadNet-Web,
threadnet-call, thread-net-git, threadnet-operating — Push-Mirrors nach Gitea,
das Flux-Quelle/Registry/Issues behält. Details: README §3 / CLAUDE.md.
3. **Windows-Build-Strecke** (ThreadNet-Web#5): eigene Windows-VM auf Overmind aus
selbst gebautem, reviewtem dockur/windows-Stand (Vendor-Repo + Runbook), On-Demand
per CI-Jobs; Gast provisioniert, Runner registriert — erster voller Build-Durchlauf
stand zum Session-Ende noch aus (Runner-Dienst-Hänger nach VM-Neustart).
4. **Lab-Container-Registry** aktiviert (`registry.git.lab`, OVERMIND-01 ✅): lab-interne
Build-Images (windows-vm, desktop-build) bleiben im Lab, rohana behält nur, was
Prod konsumiert.
5. **Gitea-CI-Rückbau** (CFGMON-11, weitgehend): Verifikations-Job hierher portiert
(`.gitlab-ci.yml`), `.gitea/workflows/` entfernt, Actions-Toggles deaktiviert,
Runner-Entfernung als Commit vorbereitet — drei manuelle Restschritte beim Nutzer.
6. **threadnet-call-CI** (threadnet-call#1): build_embedded grün, npm-Registry bleibt
evidenzbasiert auf rohana (pnpm-Lockfile-Pin), manueller Publish-Job wartet auf
`GITEA_NPM_TOKEN`-Variable.
7. **Alerting vorbereitet** (Issue #32): Alertmanager + 6 Alert-Regeln +
Matrix-Receiver in `threadnet-operating`, gelintet, Deploy steht aus (CFGMON).
8. Nebenbefunde: GitLab-Puma lief mit ~17 Workern (Unicorn-Fossil in der Config,
Fix beim Nutzer), Windows-Gast-Provisionierung als idempotenter CI-Job.
## ✅ Abgeschlossene Aufgaben (Chronologisch)
### Phase 1: Basis-Setup
@@ -270,7 +290,9 @@ verankert. Was in dieser Session erledigt wurde:
- [x] **matrix-recovery Flow gebaut** war komplett leer (0 Stages); Passwort-Reset funktioniert jetzt, verlinkt von der echten Login-Seite
- [x] **Brand.default_application gesetzt** behebt mehrere Dead-Ends, wenn eingeloggte User `/` ohne Ziel aufrufen
- [x] **2FA/Passkey Selbst-Einrichtung** Links zu `default-authenticator-totp-setup`/`-webauthn-setup` (2FA bleibt optional, `not_configured_action=skip`), dokumentiert unter `axion1337.chat/docs/setup/security.html`
- [ ] **Backlog**: **Issue #13** (MAS Custom-Template-Override für 2FA/Passkey-Link auf `account.axion1337.chat/account/`)
- [x] **Backlog**: Issue #13 geschlossen (2026-07-29) - MAS-Template-Override verworfen, MAS
unterstützt laut live geprüfter OIDC-Discovery keine 2FA/Passkey-Deep-Link-Action. Jetzt
als Client-Änderung nachgehalten: [ThreadNet-Web#4](https://rohana.axion1337.de/sorb/ThreadNet-Web/issues/4)
---
@@ -318,34 +340,22 @@ entstanden sind — nicht mehr getrennt pflegen, stattdessen die Issues aktuell
committet (Mode 644 statt 755), UND der gepinnte `matrix-js-sdk#develop`-Commit im
Lockfile ist zu alt (fehlt `src/oidc/authorize.ts`, das `apps/web` importiert). Beides
unabhängig von diesem Fix, blockiert aber jeden zukünftigen vollständigen Rebuild.
**Update 2026-07-28 (Issue #12): behoben** — Skript-Rechte korrigiert, matrix-js-sdk auf
einen funktionierenden Commit (`d19cb751`, letzter vor einem API-Breaking-Rename) gepinnt,
zusätzlich gefunden+gefixt: Element-Call-Referenz zeigte noch auf Upstream statt unseren
Fork. Mit echtem Full-Docker-Build verifiziert, siehe [[Element-Customization]] Wiki.
- Backlog: MAL-basierte Noise-Cancellation (LiveKit Agents + self-hosted DTLN/RNNoise) als
experimentelle Idee, falls später gewünscht — kein etablierter Pfad für Conferencing.
- Backlog: VP9-Codec-Präferenz erneut versuchen, sobald PR #3736 upstream gemerged/gereift
ist oder Root Cause des Ausfalls isoliert wurde (Browser-Konsolen-Repro nötig).
### Database Hardening
- [ ] **External/Dedicated PostgreSQL Deployment**
- Option 1: CloudNativePG Operator (open-source, auf K3S)
- Option 2: Managed Hetzner Postgres
- Separate aus ESS matrix-stack embedded Postgres
- HA + Replication
- Est. Effort: 12 days
- Priority: **HIGH** (reliability)
- [ ] **External/Dedizierte PostgreSQL-Migration** → [Issue #9](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/9)
- [ ] **Database Backup Strategy**
- Daily automated backups (PgBackRest oder velero)
- Off-site backup storage (S3 / Hetzner Storage Box)
- Monthly verified restores (test restore → verify data integrity)
- Backup + restore documentation
- Est. Effort: 23 days
- Priority: **CRITICAL** (disaster recovery)
- [ ] **Synapse Media PVC Backups**
- Separate backup pipeline für `/data/media_store` PVC
- Reason: Media oft >100GB, sollte nicht im DB-Backup sein
- Velero + Restic für block-level backup
- Est. Effort: 1 day
- Priority: **HIGH** (data preservation)
(Database Backup Strategy und Synapse Media PVC Backups waren hier ursprünglich als eigene
Punkte gelistet - beide erledigt und geschlossen, siehe [#6](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/6)
und [#15](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/15), Details in
[[00-TASKS]] Wiki bzw. Release v0.16.0.)
### Network Security
- [x] **NetworkPolicies K8s-Layer Segmentation** (2026-07-28, Closes #10)
@@ -372,78 +382,27 @@ entstanden sind — nicht mehr getrennt pflegen, stattdessen die Issues aktuell
- `authentik-postgresql`'s Bitnami-Chart-Policy (Port 5432, quelloffen) bewusst nicht
angefasst/dupliziert, da Helm-verwaltet.
- [ ] **Pod Security Admission (Restricted)**
- Apply to `matrix` & `authentik` namespaces
- Enforce: non-root, no privileged, read-only root fs
- Test: Ensure no chart breakage
- Est. Effort: 1 day
- Priority: **MEDIUM** (hardening)
- [ ] **Pod Security Admission (Restricted)** → [Issue #16](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/16)
### Federation & Access Control
- [ ] **Federation-Allowlist oder Closed Federation**
- Decision: Which servers to federate with?
- If allowlist: explicit `federation_domain_whitelist`
- If closed: `allow_public_rooms_without_join_rules: false`
- Synapse config in `synapse-values.yaml`
- Est. Effort: 4 hours
- Priority: **MEDIUM** (security policy)
- [ ] **Federation-Allowlist oder Closed Federation** → [Issue #17](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/17)
### Moderation & Anti-Abuse
- [ ] **Mjolnir/Draupnir Bot Deployment**
- Open-source moderation bot für Matrix
- Reason: Invitation-based, aber Federation kann Spam bringen
- Auto-ban known bad servers/users
- Spam-detection rules
- HelmChart oder custom Deployment
- Est. Effort: 12 days
- Priority: **MEDIUM** (ops safety)
- [ ] **Content Scanner for Media**
- matrix-content-scanner + ClamAV antivirus
- Scan uploaded media for malware
- Block suspicious files
- Est. Effort: 12 days
- Priority: **LOWMEDIUM** (optional but good practice)
- [x] **Draupnir Moderationsbot** → [Issue #18](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/18) (2026-07-29, deployed + live getestet)
- [x] **Content Scanner für Media** → [Issue #19](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/19) (2026-07-29, Synapse-Modul + ClamAV für unverschlüsselte Räume, plus client-seitiger Scan im ThreadNet-Web-Fork für verschlüsselte Räume/DMs - live getestet, beide Richtungen; siehe Deployment-Guide)
- [ ] **Grafana-Dashboard für ClamAV-Erkennungen** → [Issue #43](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/43)
- [ ] **ThreadNet-Web: Electron-Desktop-Build automatisieren** → [Issue #44](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/44) (kein CI-Runner, Fork-Änderungen landen aktuell nicht automatisch im Desktop-Client)
### Secrets Management
- [ ] **External-Secrets Operator oder SOPS für Flux**
- Current: SOPS with age encryption
- Consideration: External-Secrets for cloud-native (AWS Secrets Manager, Hetzner Vault, etc.)
- OR: Improve SOPS rotation strategy
- Decision needed: Keep SOPS or upgrade?
- Est. Effort: 23 days (if switching)
- Priority: **LOW** (current SOPS setup working)
- [ ] **External-Secrets Operator vs. SOPS-Setup** → [Issue #20](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/20)
### Image & Dependency Management
- [ ] **Renovate / Dependabot Setup**
- Auto-update Helm Chart versions
- Auto-update Container Image Tags
- Monitor for security patches
- Est. Effort: 4 hours
- Priority: **MEDIUM** (maintenance)
- [ ] **Trivy Image Scanning**
- Scan images in Flux HelmReleases for CVEs
- Block deployment if critical CVE found
- CI/CD hook in git workflow
- Est. Effort: 8 hours
- Priority: **LOWMEDIUM** (security posture)
- [ ] **Monitor ESS & Element Security Advisories**
- Subscribe to `element-hq` security mailing list
- Monitor `#matrix-community` security channels
- Auto-alerts on new CVEs/patches
- Est. Effort: Ongoing (low maintenance)
- Priority: **MEDIUM** (security awareness)
- [ ] **Renovate/Dependabot Setup** → [Issue #21](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/21)
- [ ] **Trivy Image Scanning** → [Issue #31](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/31)
- [ ] **Security Advisory Monitoring (ESS/Element)** → [Issue #22](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/22)
### Container Security
- [ ] **Disable automountServiceAccountToken Everywhere**
- Audit all Deployments/StatefulSets
- Disable for: Synapse, ElementWeb, MAS, Postgres, Authentik (where not needed)
- Add `automountServiceAccountToken: false` to spec.template.spec
- Test: Ensure no breakage
- Est. Effort: 4 hours
- Priority: **MEDIUM** (least-privilege)
- [ ] **automountServiceAccountToken deaktivieren wo nicht benötigt** → [Issue #23](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/23)
---
@@ -453,121 +412,52 @@ entstanden sind — nicht mehr getrennt pflegen, stattdessen die Issues aktuell
- [x] **Hetzner Cloud Firewall** Default-Deny inbound, siehe "Phase 7" oben. **Done.**
- [x] **SSH Hardening** Key-only, Root-Login disabled, Port 2248, siehe "Phase 7" oben. **Done.**
- [ ] **unattended-upgrades**
- Enable automatic security updates
- Configure: APT::Periodic::Update-Package-Lists "1";
- Configure: APT::Periodic::Unattended-Upgrade "1";
- Configure: APT::Periodic::AutocleanInterval "7";
- Est. Effort: 30 min
- Priority: **HIGH** (set & forget)
- [ ] **K3S API Security**
- Current: K3S API listening on :6443 on all interfaces (default)
- Hardening:
- Option 1: Firewall restrict :6443 to localhost only
- Option 2: K3S --bind-address + --advertise-address to WireGuard IP
- Option 3: kubectl access only via jumphost/bastion
- Est. Effort: 2 hours
- Priority: **HIGH** (API is high-value target)
- [ ] **auditd for File Integrity & Syscall Audit**
- Monitor: /etc, ~/.kube, /var/lib/rancher/k3s
- Audit rules für sensitive file changes
- Low overhead, good signal/noise ratio
- Output to syslog / centralized logging
- Est. Effort: 2 hours
- Priority: **MEDIUM** (forensics + compliance)
- [ ] **Kernel Hardening (sysctl)**
- Apply hardening recommendations from Lynis
- Key settings:
- kernel.kptr_restrict=2 (hide kernel pointers)
- kernel.dmesg_restrict=1 (restrict dmesg)
- net.ipv4.tcp_syncookies=1 (SYN flood protection)
- net.ipv4.conf.all.rp_filter=1 (reverse path filtering)
- net.ipv4.conf.all.send_redirects=0
- net.ipv6.conf.all.disable_ipv6=0 (or =1 if no IPv6 needed)
- Persist via /etc/sysctl.d/99-hardening.conf
- Est. Effort: 2 hours
- Priority: **MEDIUM** (defense in depth)
- [ ] **Lynis Security Baseline**
- Run `lynis audit system`
- Review recommendations
- Implement high-priority findings
- Aim for score >80
- Re-run quarterly
- Est. Effort: 4 hours (initial) + 1 hour quarterly
- Priority: **MEDIUM** (baseline verification)
- [x] **unattended-upgrades** (2026-07-30) war bereits aktiv (`APT::Periodic::*` seit
längerem gesetzt, Origins-Pattern deckt Debian+Debian-Security ab), nur nie dokumentiert.
Ergänzt: Pre-Update-Benachrichtigung per Mail+Matrix, fest vor dem 06:00-07:00-Update-Fenster.
Siehe [07-host-maintenance-notifications.md](deployment-guides/07-host-maintenance-notifications.md).
Closes [Issue #24](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/24)
- [ ] **K3s API Security Hardening** → [Issue #25](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/25)
- [ ] **auditd (File Integrity & Syscall Audit)** → [Issue #26](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/26)
- [ ] **Kernel Hardening (sysctl)** → [Issue #27](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/27)
- [ ] **Lynis Security Baseline** → [Issue #28](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/28)
### Cluster Layer (K3S / Kubernetes)
- [ ] **CrowdSec Integration**
- Install CrowdSec agent on host
- Connect to CrowdSec Hub (commercial platform, free tier available)
- Feed auth.log, syslog → CrowdSec for attack detection
- Auto-block IPs via local firewall or Hetzner Firewall API
- Est. Effort: 4 hours
- Priority: **MEDIUM** (proactive threat response)
- [ ] **Falco Runtime Monitoring**
- Install Falco DaemonSet in K3S
- Monitor: Shell spawning in containers, suspicious syscalls, privilege escalation
- Output to Loki / syslog
- Alert on anomalies
- Est. Effort: 1 day
- Priority: **MEDIUM** (runtime detection)
- [ ] **CrowdSec Integration** → [Issue #29](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/29)
- [ ] **Falco Runtime Monitoring** → [Issue #30](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/30)
---
## 🎯 Meilensteine (Milestones)
## 🎯 Versionierung
| Meilenstein | Beschreibung | Status | ETA |
|------------|-------------|--------|-----|
| **M1: Basis-Setup** | K3S + Flux + ESS deployed | ✅ Done | - |
| **M2: Core Matrix** | Themes, Scripts, Policies | ✅ Done | - |
| **M3: WebRTC & Monitoring** | TURN + Alloy/Prometheus/Loki | ✅ Done | - |
| **M4: Identity Provider** | Authentik Stage 1+2 (pending Stage 2) | 🔄 In Progress | ~12 days |
| **M5: Production-Ready** | DB Backups, NetworkPolicies, Security Hardening | 📋 Backlog | ~23 weeks |
| **M6: Advanced Features** | Element Call Fork, Content Scanner, Mjolnir | 📋 Backlog | ~4+ weeks |
| **M7: Enterprise-Ready** | Full compliance (DSGVO), HA setup, Disaster Recovery | 🎯 Future | ~8+ weeks |
Seit 2026-07-28 SemVer statt der alten m1-m7-Meilensteine - siehe
[Releases](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/releases) für die volle,
detaillierte Historie (aktuell bis v0.17.0) und [[00-TASKS]] im Wiki für die Konvention
(MINOR = neue Fähigkeit, PATCH = Fix/Tuning/Doku).
---
## 📊 Prioritäts-Kategorien
### 🔴 CRITICAL (do immediately)
- Hetzner Cloud Firewall setup
- Database backup strategy
- SSH hardening
Alle Punkte hier sind als Gitea-Issues nachgehalten (Nummern siehe oben/Backlog-Verweis) - diese
Kategorisierung ist nur eine grobe Einordnung, keine zweite Tracking-Quelle.
### 🟠 HIGH (do within 12 weeks)
- Authentik Stage 2 completion
- External PostgreSQL migration
- NetworkPolicies
- Element Call fork
### 🟠 HIGH
- External PostgreSQL migration (#9)
### 🟡 MEDIUM (do within 1 month)
- CrowdSec + Falco
- Mjolnir bot
- Renovate/Trivy
- PSA restricted mode
- Kernel hardening
- CrowdSec + Falco (#29, #30)
- Renovate/Trivy (#31, #32)
- K3s API Hardening, auditd, Kernel Hardening, Lynis (#25-#28)
### 🟢 LOW (nice-to-have, do if time allows)
- Content scanner (ClamAV)
- External-Secrets upgrade
- SSH port relocation
- Advanced federation rules
---
## 📝 Notes & Decision Points
### Authentik Stage 2 Blocker
**Waiting for**: User to manually configure Authentik OIDC Provider in Authentik Admin UI.
- Once done, provide Client ID + Secret
- Then: Commit Stage 2 MAS config
### Database: CloudNativePG vs. Hetzner Postgres
- **CloudNativePG**: Open-source, runs on K3S, full control
- **Hetzner Postgres**: Managed, backups included, less ops overhead
@@ -588,12 +478,13 @@ entstanden sind — nicht mehr getrennt pflegen, stattdessen die Issues aktuell
- `docs/deployment-guides/README.md` Overview
- `docs/deployment-guides/01-turn-server-setup.md` TURN
- `docs/deployment-guides/02-authentik-identity-provider.md` Authentik (Stage 1 + Stage 2 plan)
- `docs/deployment-guides/02-authentik-identity-provider.md` Authentik (Stage 1+2 + Enrollment/Recovery/2FA)
- `docs/deployment-guides/03-monitoring-integration.md` Monitoring
- `docs/deployment-guides/04-element-customization.md` Themes, Desktop
- `docs/deployment-guides/04-element-customization.md` Themes, Desktop, Element Call Fork
- `docs/deployment-guides/05-room-policies.md` Policies
- `docs/deployment-guides/06-moderation-content-scanning.md` Draupnir, ClamAV Content Scanning
- `docs/deployment-guides/07-host-maintenance-notifications.md` Host-Wartungsbenachrichtigungen
---
**Last Updated**: 2026-05-14
**Next Review**: 2026-05-21
**Last Updated**: 2026-07-30
@@ -1,7 +1,6 @@
# Authentik als Identity Provider für Matrix
**Status**: ✅ Stage 1 Deployed (Authentik läuft)
**Pending**: Stage 2 (MAS Integration)
**Status**: ✅ Deployed (Stage 1 + Stage 2 + Enrollment/Recovery/2FA, Closes Issue #7)
**Domain**: `auth.axion1337.chat`
## Überblick
@@ -41,5 +40,70 @@ Authentik = OIDC Provider für MAS → Zentrales Login + Einladungs-basierte Reg
Authentik Admin → Flows & Stages → Invitations → Create
## Enrollment/Recovery/2FA Fix (2026-07-27, Issue #7)
Der `matrix-invitation`-Flow hatte nur 2 von 5 nötigen Stages (kein Write/Password/Login) -
Nutzer wurden nie in Synapse angelegt. Behoben und als Authentik Blueprint
(`apps/authentik/authentik-blueprints.yaml`) deklarativ ins Repo übernommen: vollständiger
`matrix-invitation`-Flow (Invite → Prompt → Write → Password → Login → Redirect), leerer
`matrix-recovery`-Flow ergänzt, `Brand.default_application` gesetzt. 2FA/Passkey-Selbst-
Einrichtung optional (`not_configured_action=skip`) — ⚠️ **gilt seit 2026-08-06 nur noch
für Mitglieder, für Admins ist MFA Pflicht**, siehe unten. Auffindbar über
`axion1337.chat/docs/setup/security.html`. Details: siehe Wiki
[Authentik-OIDC.md](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/wiki/Authentik-OIDC).
**Issue #13 geschlossen (2026-07-29)**: der ursprünglich hier vorgesehene direkte 2FA-Link auf
`account.axion1337.chat/account/` (per MAS Custom-Template-Override) wurde verworfen - live
geprüfte OIDC-Discovery zeigt, dass MAS keine 2FA/Passkey-Deep-Link-Action unterstützt. Jetzt
als Client-seitige Änderung nachgehalten:
[ThreadNet-Web#4](https://rohana.axion1337.de/sorb/ThreadNet-Web/issues/4).
---
**Weitere Details**: Siehe Kapitel 2 in diesem Projekt.
## MFA-Pflicht für Admins (2026-08-06, gitops#57)
**Für Mitglieder bleibt 2FA freiwillig, für die Gruppe `authentik Admins` ist sie Pflicht.**
Umgesetzt als eigener Blueprint `admin-mfa-enforcement.yaml` in
`apps/authentik/authentik-blueprints.yaml`.
### Warum eine zweite Stage statt einer Umstellung
`not_configured_action` hängt an der **Stage**, nicht an der Bindung. Die vorhandene
`default-authentication-mfa-validation` umzustellen hätte deshalb **alle Mitglieder**
getroffen — und wäre zugleich eine Änderung an einem Objekt aus Authentiks eigenem
Blueprint gewesen.
Stattdessen: eine eigene `admin-mfa-validation` auf Ordnung **31**, direkt hinter der
Standard-Stage (30) und vor dem Login (100), eingeschränkt über eine `PolicyBinding`
mit gesetztem `group`. Eine solche Bindung wertet Gruppenmitgliedschaft aus
(`PolicyResult(group.is_member(user))`). **Kein Authentik-Standardobjekt wird
angefasst.**
### Die drei Einstellungen, auf die es ankommt
| Feld | Wert | Warum |
|---|---|---|
| `not_configured_action` | `configure` | führt durch die Einrichtung, statt auszusperren |
| `configuration_stages` | TOTP + WebAuthn | sonst kann `configure` nichts anbieten |
| `last_auth_threshold` | `hours=1` | verhindert die doppelte Abfrage |
⚠️ **`configure`, niemals `deny`.** `deny` weist Admins ohne zweiten Faktor ab — und
danach gibt es keinen Weg zurück außer über den Cluster. `configure` erzwingt
genauso, führt aber durch die Einrichtung.
⚠️ **`last_auth_threshold` ist kein Beiwerk.** Die Standard-Stage auf Ordnung 30
validiert bereits, wer einen Faktor besitzt. Ohne Schwelle (`seconds=0`, der Default)
würde unsere Stage direkt danach ein zweites Mal fragen. Mit `hours=1` überspringt
sie sich, wenn das Gerät gerade benutzt wurde — übrig bleibt genau der Zielfall:
Admin ohne zweiten Faktor.
### Die Falle beim Ändern
**Der Gruppenname ist die ganze Wirkung.** Stimmt er nicht, greift die Regel für
**niemanden** — und wirft dabei keinen Fehler. Das ist schlechter als keine Regel,
weil es sich sicher anfühlt. Wer die Gruppe umbenennt, muss den Blueprint mitziehen.
Prüfen lässt sich die Wirkung nur an einem Konto, das in der Gruppe ist: anmelden und
sehen, ob nach dem Passwort die Einrichtung kommt.
+37 -14
View File
@@ -3,17 +3,18 @@
**Status**: ✅ Vollständig deployed
**Domains**: `axion1337.chat` (Web), `/docs/setup` (Scripts)
## 1. Custom Themes (7 Stück)
## 1. Custom Themes (17 Stück, Stand 2026-08-06)
| Theme | Primärfarbe |
|-------|-----------|
| aXion1337 Dark | `#1a1a1a` |
| Deep Purple | `#6a4c93` |
| Discord Dark | `#2c2f33` |
| Electric Blue | `#0066ff` |
| Everforest Dark Hard | `#1e2326` |
| Gruvbox Dark | `#282828` |
| Wal | `#1e1e1e` |
⚠️ Diese Zahl und die Namen waren hier bis 2026-08-09 veraltet (stand auf „7
Stück", nannte u. a. „Gruvbox Dark" und „Wal", die es in der Config so nicht
gibt) — korrigiert, nachdem der tatsächliche Bestand gegen `element-values.yaml`
geprüft wurde.
Die Liste **mit Farbwerten und hell/dunkel-Kennzeichnung** lebt bewusst nur an
einer Stelle, um genau dieses Auseinanderlaufen nicht zu wiederholen:
[`management/shared/branding.md`](https://git.lab/axion1337.chat/management/-/blob/main/shared/branding.md#theme-factory--die-zehn-benannten-themes)
(Abschnitt „theme-factory" für die zehn neueren, „Stammschema" für die sieben
älteren).
**Konfiguration**: `apps/production/custom-configs/element-values.yaml`
@@ -72,10 +73,32 @@
`ThreadNet-Web` einen vorbestehenden Build-Bug hat (siehe unten).
- Config live prüfbar: `https://axion1337.chat/widgets/element-call/config.json`
**Bekannter, nicht behobener Bug in `ThreadNet-Web`**: `scripts/docker-link-repos.sh` /
`docker-package.sh` sind nicht ausführbar committet (Mode 644), und der gepinnte
`matrix-js-sdk#develop`-Commit im Lockfile ist zu alt (fehlt `src/oidc/authorize.ts`) —
blockiert einen kompletten Neu-Build des Forks von Grund auf.
**Update 2026-07-28 (Issue #12) — Full-Rebuild-Blocker behoben**: der oben beschriebene
Patch-Workaround war nötig, weil `ThreadNet-Web` komplett neu gebaut nicht funktionierte.
Drei Bugs gefixt: (1) 7 Skripte nicht ausführbar committet (644 statt 755, betraf auch die
GitHub-Actions-Workflows des Forks), (2) `matrix-js-sdk#develop`-Pin auf einen veralteten
Commit resolved (fehlte `src/oidc/authorize.ts`) — gepinnt auf `d19cb751` (letzter Commit
vor dem Rename `src/oidc/``src/oauth/` mit geänderter API), (3) `package.json`/
`webpack.config.ts` referenzierten noch upstream `@element-hq/element-call-embedded` statt
unseren Fork. Mit echtem, vollständigem `docker build` aus frischem Klon verifiziert.
Details: [[Element-Customization]] Wiki-Seite. Produktivumgebung bleibt beim Patch-Image.
**Update 2026-07-28 (später) — Video-Tab statt Developer-Mode**: Kamera-/Screen-Share-
Qualitätseinstellungen (Auflösung, Framerate, Bitrate, Codec) waren im Upstream-PR #3736
hinter einem "Developer Mode"-Schalter versteckt — in unseren Fork in den regulären
"Video"-Settings-Tab verschoben, für alle Nutzer sichtbar. Deutsche Übersetzungen ergänzt
(fehlten komplett). Codec-Dropdown auf die tatsächlich von der SFU akzeptierten Codecs
beschränkt (VP8/H.264/H.265 — live per SFU-Logs verifiziert; VP9/AV1 wurden von der SFU
ohnehin nur transparent auf VP8 zurückgefallen, boten aber keinen echten Effekt).
**Update 2026-07-29 — VP9/AV1 live getestet, zurückgerollt (Issue #11)**: SFU-seitige
Codec-Freigabe (`matrixRTC.sfu.additional`) + Dropdown-Wiederfreischaltung getestet. Trotz
echter Auswahl auf Safari und Desktop-Firefox (mit frischem Call-Rejoin) fiel VP9 immer
automatisch auf VP8 zurück. SFU-Logs zeigten: die eigene Codec-Freigabe kam serverseitig nie
in der aktiven `enabledPublishCodecs`-Liste an — Ursache nicht abschließend geklärt (möglicher
Zusammenhang: `sfu.additional` ersetzt die Chart-eigene `config-overrides.yaml` im Config-Merge,
statt sie zu ergänzen). Komplett zurückgerollt auf den bekannt funktionierenden 3-Codec-Stand.
Details: Issue #11.
## Dateien
@@ -0,0 +1,213 @@
# Moderation Bot & Content Scanning
**Status**: ✅ Draupnir deployed (2026-07-29, Closes Issue #18) | ✅ Content Scanner deployed + live getestet (2026-07-29, Closes Issue #19)
**Konfiguration**: `apps/production/draupnir*.yaml`, `apps/production/clamav*.yaml`, `apps/production/clamav_spam_checker.py`
## 1. Draupnir (Moderationsbot)
Community-Nachfolger von Mjolnir. Läuft als eigener Bot-Account (`@draupnir:axion1337.chat`),
verwaltet Ban-Listen ("Policy Rooms") und setzt sie in geschützten Räumen durch.
### Warum Draupnir statt Mjolnir?
Mjolnir gilt als Vorgänger-Projekt und wird von der Community nicht mehr aktiv weiterentwickelt;
Draupnir ist der aktive Fork mit denselben Kernfunktionen plus Erweiterungen (u.a. native
Rust-Crypto-Unterstützung, siehe unten).
### Bot-Account & Zugriff (Bootstrap)
Da Authentifizierung über MAS läuft (kein klassisches `registration_shared_secret`), wird der
Bot-Account über MAS' eigenes CLI-Tool angelegt:
```bash
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage register-user draupnir --yes
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage issue-compatibility-token draupnir
```
Der ausgegebene Token wird per `sops apps/production/draupnir-secret.yaml` manuell eingetragen
(kein automatisierter Schritt - der Token darf nirgends unverschlüsselt landen).
### Wichtige Stolpersteine (live gefunden, nicht aus der Doku ableitbar)
- **Version**: `gnuxie/draupnir:v2.9.0` crasht beim ersten Start mit `initialManager`
("Can't join remote room because no servers..."). Das automatische Anlegen des
Management-Rooms über `initialManager` funktioniert erst **ab v3.1.0**. Aktuell deployt:
`v3.1.0`.
- **CLI-Argument statt Env-Var**: v3.x hat die automatische Config-Erkennung über
`NODE_CONFIG_DIR` entfernt - der Container braucht jetzt explizit
`args: ["bot", "--draupnir-config", "/data/config/default.yaml"]`, sonst
`TypeError: No configuration path has been found for Draupnir.` (per Extraktion von
`dist/config.js` aus dem Image bestätigt, nicht dokumentiert gefunden).
- **NetworkPolicy**: Der Bot muss Synapse direkt anrufen können. Da `matrix-stack-synapse`
intern über haproxy geroutet wird und `allow-ingress-haproxy` standardmäßig nur Traefik
(`kube-system`) erlaubt, braucht Draupnir eine eigene `podSelector`-Ausnahme in
`networkpolicy.yaml` - sonst schlägt jede Anfrage an den Homeserver silent fehl.
### Verschlüsselter Management-Room
Standardmäßig unverschlüsselt (Draupnirs zugrundeliegende Bot-Library aktiviert Crypto nicht
automatisch). Für einen verschlüsselten Management-Room:
1. `experimentalRustCrypto: true` in der Config ergänzen (via `sops`) - vom Hersteller selbst
als "not considered production safe" gekennzeichnet, in unserem Test aber ohne Fehler
gelaufen (Pod stabil, kein Crash, `End-to-end encryption enabled` in den Logs).
2. Verschlüsselung ist eine Raum-Eigenschaft, die beim Erstellen gesetzt wird - das Flag allein
verschlüsselt einen bereits bestehenden Management-Room **nicht** rückwirkend. Dafür in
Element: Raumeinstellungen → Sicherheit & Datenschutz → Verschlüsselung aktivieren.
### Profilbild setzen
Erfordert eine `mxc://`-URL (Bild muss zuerst hochgeladen werden, z.B. per Chat an den Bot
senden, dann in Element per "View Source" die `mxc://`-URL kopieren):
```
!draupnir avatar mxc://<server>/<media-id>
```
### Befehle (Kurzreferenz)
Alle Befehle im (verschlüsselten) Management-Room, Präfix `!draupnir`:
| Befehl | Zweck |
|--------|-------|
| `status` | Bot-Status, beobachtete Listen, geschützte Räume |
| `rooms add <room>` | Raum unter Draupnirs Schutz stellen (Voraussetzung für Bans!) |
| `list create <shortcode> <alias>` | Neue Policy-Liste anlegen (wird automatisch beobachtet + geschützt) |
| `watch <shortcode>` | Zusätzliche Policy-Liste beobachten |
| `ban <user> <liste> <grund>` | **Wichtig**: 2. Argument ist die Policy-Liste, NICHT der Ziel-Raum! Der Ban gilt automatisch in allen Räumen, die diese Liste beobachten und geschützt sind |
| `kick <user> <room> <grund>` | Direkter, sofortiger Kick aus einem konkreten Raum (ohne Listen-Umweg) |
| `rules` | Zeigt die Regeln einer Policy-Liste an |
| `unban <user> <liste>` | Regel wieder entfernen |
**Live getestet** (2026-07-29): Testraum geschützt, Policy-Liste angelegt, Testnutzer über
`ban`+Liste erfolgreich aus dem geschützten Raum entfernt. Kernmechanismus bestätigt
funktionsfähig.
## 2. Content Scanner (Issue #19)
**Verworfener erster Ansatz**: `matrix-content-scanner-python` ist ein Proxy, den der
**Client** explizit statt der normalen Media-Endpunkte aufrufen muss - Synapse selbst leitet
nichts automatisch dorthin um. Diese client-seitige Unterstützung existiert nur noch in
veralteten, nicht mehr gepflegten Android/iOS-SDKs; weder aktuelles Element Web noch Element X
unterstützen das (geprüft: kein `content_scanner`-Hook im offenen `element-x-android`-Repo).
Element selbst hat echtes serverseitiges Scanning - aber nur in der kommerziellen
**Element Pro** + **ESS Pro**-Kombination, nicht in unserer offenen ESS-Community-Installation.
**Tatsächlich umgesetzt**: ein eigenes, kleines Synapse-Modul (`clamav_spam_checker.py`),
das Synapses echten, dokumentierten Hook `check_media_file_for_spam` nutzt - läuft
**serverseitig**, transparent für jeden Client, ganz ohne Mitwirkung des Clients. Kein
fertiges Modul dafür existiert (auch das verbreitete `synapse-http-antispam`-Brückenmodul
schließt genau diesen Callback explizit aus), daher selbst geschrieben.
**Architektur**:
- ClamAV (`clamav/clamav:1.5.3`) läuft als eigener Pod, PVC für die Signatur-Datenbank.
- Das Modul (`apps/production/clamav_spam_checker.py`) wird per ConfigMap gemounted und über
`PYTHONPATH` importierbar gemacht (`synapse.extraVolumes`/`extraVolumeMounts`/`extraEnv` -
kein Custom-Synapse-Image nötig).
- Spricht ClamAVs natives INSTREAM-Protokoll direkt über **Twisted**-Netzwerk-Primitives
(`HostnameEndpoint`/`connectProtocol`), nicht über `asyncio` - Synapse läuft auf Twisteds
Reactor, nicht auf einer laufenden asyncio-Event-Loop. Ein erster Versuch mit
`asyncio.open_connection`/`wait_for` schlug live mit `RuntimeError: no running event loop`
fehl und fiel dadurch (durch das eigene Fail-Open-Verhalten) unbemerkt auf "durchlassen"
zurück - die EICAR-Testdatei wurde beim ersten Versuch nicht erkannt. Nach Umstellung auf
Twisted-Primitives funktioniert es sauber.
- **Fail-open** bei Scanner-Fehlern (Verbindungsfehler/Timeout → Datei wird durchgelassen,
laut geloggt) - ein ClamAV-Ausfall soll nicht alle Uploads auf dem Homeserver blockieren.
**Live getestet und bestätigt** (2026-07-29):
- Normale Datei in unverschlüsseltem Raum → läuft durch (kein Regressionsschaden).
- EICAR-Testdatei in unverschlüsseltem Raum → zuverlässig blockiert
(`ClamAV rejected an upload: Eicar-Test-Signature`, Client bekommt `400 Bad content` -
Synapse gibt bewusst keine Begründung an den Client zurück, nur in den Server-Logs sichtbar).
- EICAR-Testdatei in verschlüsseltem Raum/DM → **läuft durch** - erwartete, strukturelle
Grenze: Synapse hat bei E2EE nie den Entschlüsselungsschlüssel, sieht nur Ciphertext. Nur
ein kooperierender Client könnte das lösen (siehe oben, existiert nicht offen verfügbar).
**Bekannte Deckungslücke (Stand vor der Client-Erweiterung unten)**: schützt nur
unverschlüsselte Räume/DMs - keine Warnung/Kennzeichnung für Nutzer in verschlüsselten
Räumen, dass dort kein Scanning stattfindet. Folgeidee (Issue #43, LOW): Grafana-Dashboard
über die bestehenden Loki-Logs, um Erkennungen/Scanner-Ausfälle sichtbar zu machen.
## 3. Client-seitiges Scanning für verschlüsselte Räume (Issue #19-Erweiterung, 2026-07-29)
Da Synapse bei E2EE-Räumen strukturell nie den Schlüssel hat, kann nur der **Client**
Klartext scannen - einmal beim Senden (vor der Verschlüsselung), einmal beim Empfangen
(nach der Entschlüsselung). Umgesetzt in `ThreadNet-Web` (Fork von Element Web).
### Architektur
Ein neuer, eigener HTTP-Dienst (`apps/production/clamav-http-scanner.py`, eigenes Image via
`clamav-http-scanner-Dockerfile`) macht denselben ClamAV-Pod für Browser-JS erreichbar
(clamd spricht nur rohes TCP, das kann ein Browser nicht). Erreichbar unter
`https://axion1337.chat/_scan`. Auth über Synapses eigenen
`/_matrix/client/v3/account/whoami`-Endpunkt (kein eigenes Auth-System nötig) - verhindert,
dass der Dienst zu einem offenen "teste dein Malware gegen unseren Virenscanner"-Orakel für
das ganze Internet wird. Fail-open bei Scanner-Fehlern, wie beim Synapse-Modul.
**Zwei Patch-Stellen im `ThreadNet-Web`-Fork** (im Repo
`rohana.axion1337.de/sorb/ThreadNet-Web.git`, nicht in diesem gitops-Repo):
- **Empfang**: `apps/web/src/utils/DecryptFile.ts`, Funktion `decryptFile()` - der einzige
Punkt im ganzen Client, an dem entschlüsselte Klartext-Bytes für *jeden* Anhangstyp
entstehen (Bild/Audio/Video/Datei laufen alle über `MediaEventHelper` hier durch). Scan
direkt nach dem Entschlüsseln, vor der Rückgabe als `Blob`.
- **Versand**: `apps/web/src/ContentMessages.ts`, Funktion `uploadFile()` - die eine
gemeinsame Funktion für alle Anhangs-Uploads (Hauptdatei, generierte Thumbnails,
Sprachnachrichten), unabhängig davon ob der Zielraum verschlüsselt ist. Scan direkt nach
dem Einlesen der Datei, vor Verschlüsselung/Upload.
- Gemeinsame Hilfsdatei: `apps/web/src/utils/ContentScanner.ts` (neue `scanContent()`-
Funktion + `ContentScanRejectedError`), von beiden Stellen genutzt. Fehlertexte über die
bereits bestehenden Error-Rendering-Pfade in `MImageBody.tsx`/`MAudioBody.tsx`/
`VideoBodyViewModel.ts`/`FileBodyViewModel.ts` (gleiches Muster wie die schon vorhandenen
`DecryptError`/`DownloadError`).
**Live getestet** (2026-07-29):
- EICAR in verschlüsseltem Gruppenraum ("testgruppe") und in 1:1-DMs zwischen zwei echten
Accounts - in beiden Fällen zuverlässig **vor dem Upload** blockiert. Vorher (nur
Synapse-Modul) lief das durch.
- Empfangsseite unabhängig vom Absender bestätigt: EICAR über einen echten, ungepatchten
Client (app.element.io) in denselben verschlüsselten Raum geschickt (simuliert einen
fremden/föderierten Absender ohne unseren Patch) - beim Download-/Anzeigeversuch im
gepatchten `ThreadNet-Web`-Client greift der Scanner zuverlässig. Beweist, dass der
Empfangs-Hook unabhängig vom sendenden Client funktioniert, nicht nur als Selbstschutz
für eigene Uploads.
### ⚠️ Wichtig für Desktop-/Electron-Builds (korrigiert, siehe Issue #44)
**Dieser Fix ist im Web-Client (Browser, das laufende `threadnet-web`-Container-Image)
bestätigt live wirksam. Ob er auch im Electron-Client wirkt, hängt am tatsächlichen
Build-Prozess - und der ist aktuell nicht automatisiert.**
Element Desktop (`apps/desktop` im selben Monorepo) baut die Web-App nicht selbst, sondern
packt ein fertiges `webapp`-Verzeichnis in ein `webapp.asar`. *Woher* dieses Verzeichnis
kommt, hängt vom Aufrufer ab:
- **Standard-Fallback** (`pnpm run fetch <version>` ohne Artefakt): lädt ein offiziell von
`element-hq/element-web` signiertes Release-Tarball herunter - **Upstream, ohne unsere
Patches**.
- **Mit eigenem Build** (`webapp-artifact`-Mechanismus in `build_desktop_prepare.yaml`,
gedacht für CI): würde unseren eigenen `apps/web`-Output übernehmen, **inklusive** aller
Fork-Anpassungen.
Der zweite Weg ist im Repo als GitHub-Actions-Pipeline (`build-and-test.yaml`) angelegt,
läuft aber **nicht automatisch** - kein registrierter Runner, und der vorgelagerte Build-Job
checkt zudem noch `element-hq/element-web` (Upstream) statt des eigenen Forks aus, ein Rest
der ursprünglichen Upstream-CI. Die bereits existierende Desktop-Build (mit der
Discord-Style-Raumliste) entstand nach aktuellem Stand aus einem **manuellen, lokalen**
Build-Durchlauf, nicht aus einem reproduzierbaren, automatisierten Prozess.
**Konsequenz für heute**: die Scan-Patches sind im `ThreadNet-Web`-Fork-Code enthalten und
würden in jedem zukünftigen (manuellen oder automatisierten) Desktop-Build aus diesem Fork
mitkommen - sie sind aber **nicht automatisch** in einer bereits existierenden
Desktop-Installation gelandet, ohne dass jemand den Build-Vorgang erneut manuell durchführt.
Neues Backlog-Item dafür angelegt:
[Issue #44](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/44) - Build-Job
auf den eigenen Fork umstellen + funktionierenden Runner aufsetzen, damit Fork-Änderungen
zuverlässig und automatisch auch im Desktop-Client landen.
**Element X (Mobile, iOS/Android)** ist davon komplett unberührt - eigene Codebasis auf
Basis von `matrix-rust-sdk`, kein gemeinsamer Code mit `ThreadNet-Web`. Ein Schutz dort
wäre ein separates, eigenständiges Projekt.
@@ -0,0 +1,149 @@
# Host-Wartungsbenachrichtigungen (Pre-Update Mail & Matrix)
**Status**: ✅ Deployed + live getestet (2026-07-29/30, Closes Issue #24)
**Konfiguration**: `host-config/maintenance-notify/` (nicht via Flux/GitOps deployt - siehe unten warum)
## Überblick
Der Host läuft bereits mit aktivem `unattended-upgrades`
(`APT::Periodic::Update-Package-Lists`/`Unattended-Upgrade` in
`/etc/apt/apt.conf.d/20auto-upgrades`, Standard-Origins-Pattern deckt
`Debian`+`Debian-Security` ab). Das ist unabhängig von diesem Dokument und war schon vor
Issue #24 aktiv - nur nie dokumentiert.
Was hier ergänzt wird: eine Benachrichtigung **vor** dem täglichen Update-Lauf, per E-Mail
und Matrix, damit man weiß "gleich läuft ein Update" und im Störungsfall danach sofort den
Zusammenhang sieht. `Unattended-Upgrade::Mail` (auskommentiert in
`50unattended-upgrades`) wäre keine Alternative gewesen: die feuert nur *nach* dem Lauf und
braucht ohnehin ein lokales `mailx`-Setup.
Diese Anleitung ist bewusst **generisch** gehalten - sie funktioniert für jeden Fork dieses
Homeserver-Stacks, nicht nur für axion1337.chat. Alle instanzspezifischen Werte (Domain,
Matrix-Raum, Mail-Adressen) stecken in einer separaten Config-Datei, nicht im Skript selbst.
Ein konkretes, reales Beispiel (axion1337.chat) steht am Ende.
## Warum nicht via Flux/GitOps?
Alles andere in diesem Repo landet via Flux im Cluster. Diese Automatisierung läuft aber
**auf dem nackten Host** (systemd-Timer, kein Kubernetes-Pod) - dafür existiert in diesem
Repo (noch) kein Deployment-Mechanismus (kein Ansible, kein SOPS-Agent auf dem Host). Das
Skript selbst ist trotzdem hier versioniert (`host-config/maintenance-notify/`), das
Deployment auf den Host erfolgt aber manuell per `scp`/SSH.
## Architektur
- **Timing**: `apt-daily-upgrade.timer` führt den echten Update-Lauf aus
(`OnCalendar=*-*-* 6:00`, `RandomizedDelaySec=60m` → tatsächlicher Start irgendwann
zwischen 06:00-07:00, je nach eurer eigenen Konfiguration ggf. abweichend - mit
`systemctl cat apt-daily-upgrade.timer` prüfen). Der neue `maintenance-notify.timer`
feuert **fest** vor diesem Fenster (Default `05:00`, kein Randomize).
- **Prüfung**: `maintenance-notify.sh` ruft `apt-get update` + `unattended-upgrade --dry-run -v`
auf und liest dessen eigene, im Quellcode verifizierte Log-Zeilen (`/usr/bin/unattended-upgrade`):
- `"No packages found that can be upgraded unattended..."` → nichts ansteht, Skript beendet
sich ohne jede Benachrichtigung (kein täglicher Alarm-Spam).
- `"Packages that will be upgraded: <liste>"` → genau die Pakete, die der echte Lauf gleich
anfassen wird.
- **Zustellung** (nur wenn Pakete anstehen):
- **Mail** via `msmtp`, Passwort kommt aus `/etc/maintenance-notify/mail-password`
(chmod 600, nie im Repo).
- **Matrix** via `curl` gegen die Client-Server-API, als Reply in einem bestehenden Thread
(`m.relates_to: {rel_type: "m.thread", event_id: ...}`), Bot-Token aus
`/etc/maintenance-notify/matrix-token` (chmod 600, nie im Repo).
## Voraussetzungen
- Ein Mail-Provider mit SMTP-Auth (eigenes Postfach zum *Versenden*, nicht zwingend zum
Empfangen - der Empfänger kann eine ganz andere, bereits bestehende Adresse sein).
- Ein Matrix-Raum (und optional ein bestehender Thread darin), in den ein eigener Bot-Account
eingeladen wird.
- Auf dem Host: `msmtp`, `jq`, `uuid-runtime` (`apt-get install -y msmtp jq uuid-runtime`).
## Deployment
1. **Bot-Account anlegen** (identisches Muster wie für Draupnir/den Content-Scanner in
[06-moderation-content-scanning.md](06-moderation-content-scanning.md)):
```bash
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage register-user maintenance-notify --yes
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage issue-compatibility-token maintenance-notify
```
Der ausgegebene Token wird **manuell** in `/etc/maintenance-notify/matrix-token` auf dem
Host eingetragen (chmod 600) - kein automatisierter Schritt, der Token darf nirgends im
Klartext im Repo landen.
2. **Bot in den Zielraum einladen UND joinen lassen.** Eine Einladung allein reicht nicht -
der Account muss aktiv beitreten, sonst kann er nicht senden:
```bash
curl -s -X POST -H "Authorization: Bearer $(cat /etc/maintenance-notify/matrix-token)" \
"https://<euer-homeserver>/_matrix/client/v3/join/<room-id>"
```
3. **Skript + systemd-Units auf den Host kopieren** (aus
`host-config/maintenance-notify/` in diesem Repo):
```bash
scp host-config/maintenance-notify/maintenance-notify.sh <host>:/tmp/
scp host-config/maintenance-notify/maintenance-notify.{service,timer} <host>:/tmp/
ssh <host> "sudo install -m 755 /tmp/maintenance-notify.sh /usr/local/bin/maintenance-notify.sh && \
sudo install -m 644 /tmp/maintenance-notify.service /etc/systemd/system/ && \
sudo install -m 644 /tmp/maintenance-notify.timer /etc/systemd/system/ && \
sudo mkdir -p /etc/maintenance-notify && sudo systemctl daemon-reload"
```
4. **Config-Datei anlegen** (`config.example` in diesem Verzeichnis als Vorlage nach
`/etc/maintenance-notify/config` kopieren, alle Werte für eure Instanz anpassen).
**Wichtig**: Matrix-Event-IDs beginnen mit `$` - der `MATRIX_THREAD_EVENT_ID`-Wert muss
single-quoted sein, sonst versucht bash ihn als Variable zu expandieren und schneidet ihn
auf einen leeren String zusammen.
5. **`msmtprc.template` nach `/etc/msmtprc` kopieren**, Platzhalter ausfüllen, chmod 600.
Passwort selbst kommt nicht hier rein, sondern separat in
`/etc/maintenance-notify/mail-password` (chmod 600, eine Zeile, **kein** SMTP-Passwort
ohne vorheriges eigenes Testen der Zugangsdaten übernehmen - siehe Stolpersteine unten).
6. **Timer aktivieren**:
```bash
sudo systemctl enable --now maintenance-notify.timer
```
## Verifikation
```bash
sudo systemctl start maintenance-notify.service
sudo journalctl -u maintenance-notify.service --no-pager -n 40
sudo systemctl list-timers maintenance-notify.timer
```
Bei nichts anstehenden Updates loggt das Skript nur `"No pending upgrades - nothing to notify."`
und beendet sich sauber (kein Fehlerfall). Für einen echten Zustellungstest (Mail + Matrix)
unabhängig vom tatsächlichen Update-Status können die `send_mail`/`send_matrix`-Bausteine aus
dem Skript manuell mit einer Testnachricht nachgestellt werden.
## Stolpersteine (live gefunden, nicht aus der Doku ableitbar)
- **Port 465 kann ausgehend blockiert sein, obwohl 587 durchgeht.** Bei axion1337.chat war
ausgehendes SMTPS (465) sowohl zu IONOS als auch testweise zu Gmail dicht (stiller Timeout,
kein aktives Reject - typisch für eine Firewall-Regel auf Cloud-Provider-Ebene), während
587/STARTTLS problemlos funktionierte. Vor dem Debuggen von Auth-Fehlern erst die reine
TCP-Erreichbarkeit prüfen: `timeout 8 bash -c 'echo > /dev/tcp/<host>/<port>'`.
- **`msmtp`'s `passwordeval` nimmt die Ausgabe wörtlich**, inklusive eines eventuellen
Trailing-Newlines aus der Passwort-Datei. `printf %s "$(cat datei)" > datei` entfernt das
zuverlässig.
- **Absender-Domain ≠ Matrix-Server-Domain.** Es ist nicht garantiert, dass das Mail-Postfach
unter derselben Domain läuft wie der Matrix-Homeserver (bei axion1337 z.B. Mail unter
`.de`, Matrix unter `.chat`) - `MAIL_FROM` und der `user`/`from` in `msmtprc` müssen zur
tatsächlichen Mail-Domain passen, nicht zur Matrix-Domain.
- **`MATRIX_HOMESERVER` ist oft eine eigene Subdomain, nicht die Apex-Domain.** Vor dem
Eintragen die eigene `.well-known/matrix/client`-Delegation prüfen
(`curl https://<apex-domain>/.well-known/matrix/client`, Feld `m.homeserver.base_url`).
- **535 "Authentication credentials invalid" trotz korrektem Passwort?** Manche
Mail-Provider trennen Postfach-Login und SMTP/IMAP-Zugriff als separaten Schalter in den
Postfach-Einstellungen - vor weiterem Debugging prüfen, ob dieser aktiviert ist.
## Beispiel: axion1337.chat
- Homeserver: `https://matrix.axion1337.chat` (nicht die Apex-Domain)
- Matrix-Ziel: Space "operating" → Raum `wartung`, Reply in einem vorab angelegten Thread
- Mail: Absender `wartung@axion1337.de` (eigene Mail-Domain, getrennt von `axion1337.chat`)
über IONOS SMTP (`smtp.ionos.de:587`, STARTTLS), Empfänger die private Hauptadresse des
Betreibers
- Timer: `OnCalendar=*-*-* 05:00` (fest), reales Update-Fenster 06:00-07:00
@@ -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.
@@ -0,0 +1,268 @@
# Docusaurus-Wiki hinter Authentik (Forward-Auth)
**Status**: vorbereitet, **nicht** ausgerollt · Host: **`axionwiki.lab`**
(#0024 entschieden 2026-08-12) · Entscheidung: bei Docusaurus bleiben, Zugang per
Authentik (DOC-03/#0020)
Docusaurus ist ein **statischer** Site-Generator — kein Nutzermodell, kein Login.
Der Zugang wird deshalb **im Reverse-Proxy davor** erzwungen: Traefik fragt bei
jedem Request einen Authentik-**Outpost**, ob eine gültige Sitzung vorliegt, und
leitet sonst zu `auth.axion1337.chat` um. Erst nach Login gibt Traefik die
statischen Seiten frei. Es ist ein **Alles-oder-nichts-Tor** (wer in der Gruppe
`wiki-zugang` ist, sieht das ganze Wiki; sonst nichts) — für Bereichs-Rechte
bräuchte es BookStack (ADR-0007), das war aber ausdrücklich nicht gewollt.
## ⚠️ Nur für die Entwicklung — das hier ist ein Zwischenstand
Diese Fassung (Docusaurus auf Overmind unter `axionwiki.lab`, Forward-Auth über
den Prod-Authentik) trägt **während der Entwicklung**. Zwei Dinge stehen bewusst
noch aus und dürfen dabei nicht in Vergessenheit geraten:
1. **Das Wiki zieht in die ThreadNet Server Suite um.** Es soll Teil des
reproduzierbaren Stacks werden (Vision „reproduzierbar für Dritte"), nicht ein
Einzelstück auf dem Lab-Host. Dann ändern sich Host, Proxy und ggf. die
Auth-Anbindung erneut. → **[management-Issue: Wiki in die ThreadNet Server
Suite umziehen]**
2. **Danach werden Oberflächen-Alternativen über BookStack hinaus geprüft.** Die
Docusaurus-Entscheidung gilt für jetzt; die breitere Evaluation (nicht nur
Docusaurus vs. BookStack) kommt nach dem Umzug. → **[management-Issue:
Wiki-Oberfläche über BookStack hinaus prüfen]**
Alles unten ist deshalb so gebaut, dass es **jetzt** funktioniert und beim Umzug
**sauber ablösbar** ist (eigener Outpost, keine Verdrahtung in fremde Stacks).
## Topologie
Das Wiki läuft als Dokploy-Stack **auf Overmind** (`git.lab/homelab/wiki`,
`axionwiki.lab`), Authentik im **K3s-Cluster auf Hetzner**. Deshalb ein
**eigener Proxy-Outpost als Container auf Overmind**, statt des eingebetteten
Outposts in Hetzner: So bleiben die Auth-Subrequests lokal auf Overmind — nur die
**Login-Umleitung** quert den Site-to-Site-VPN zu `auth.axion1337.chat`. Das passt
zur Leitlinie „das Lab hängt nicht am Prod-Host": ist eine Sitzung erst gesetzt,
liest sich das Wiki im Lab auch ohne ständige Rückfrage nach Hetzner.
```
Browser ──▶ Traefik (Overmind) ──forwardAuth──▶ authentik-proxy (Overmind, :9000)
│ │ Sitzung? nein ──▶ 302
└────────────── Login-Redirect ─────────────▶ auth.axion1337.chat (Hetzner, via VPN)
```
## Teil 1 — Authentik-Blueprint (deklarativ)
Als neues Dokument in `apps/authentik/authentik-blueprints.yaml` einfügen
(gleiche ConfigMap wie `matrix-oidc-provider.yaml`). Host ist entschieden
(`axionwiki.lab`), also apply-fertig — trotzdem erst mit Teil 2/3 zusammen scharf
schalten.
```yaml
wiki-forward-auth.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: wiki-forward-auth
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# Proxy-Provider im Forward-Auth-Modus. Kein internal_host (das wäre der
# Proxy-Modus) — forward_single reicht Traefik nur das Ja/Nein zurück.
- model: authentik_providers_proxy.proxyprovider
state: present
identifiers:
name: ThreadNet Wiki
id: wiki_proxy_provider
attrs:
mode: forward_single
external_host: https://axionwiki.lab
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
access_token_validity: hours=24
- model: authentik_core.application
state: present
identifiers:
slug: wiki
id: wiki_app
attrs:
name: ThreadNet Wiki
provider: !KeyOf wiki_proxy_provider
meta_description: Internes Docusaurus-Wiki, nur Gruppe wiki-zugang
policy_engine_mode: any
open_in_new_tab: true
# Zugangsbeschränkung: nur Mitglieder dieser Gruppe passieren das Tor.
- model: authentik_core.group
state: present
identifiers:
name: wiki-zugang
id: wiki_group
- model: authentik_policies.policybinding
state: present
identifiers:
target: !KeyOf wiki_app
group: !KeyOf wiki_group
order: 0
attrs:
enabled: true
negate: false
```
⚠️ **Vor dem Merge von Hand verifizieren**, nicht auf Flux vertrauen — ein
Blueprint-Fehler scheitert still (Lehre aus dem `matrix-recovery`-Fix). Nach dem
Einspielen prüfen, dass der Lauf `successful` ist:
```bash
kubectl exec -n authentik authentik-postgresql-0 -- sh -c \
'PGPASSWORD="$(cat "$POSTGRES_PASSWORD_FILE")" psql -U authentik -d authentik -At -c \
"SELECT name, status FROM authentik_blueprints_blueprintinstance WHERE name='"'"'wiki-forward-auth'"'"'"'
```
## Teil 2 — Outpost + Token in Authentik (**sorb**, Schritt für Schritt)
Der Outpost ist der Prozess, den Traefik fragt; sein **Token** ist ein Credential
und kommt **nicht** ins Repo.
**2.1 — Laufende Authentik-Version feststellen** (der Outpost-Container muss
*exakt* dieselbe Version tragen, sonst verweigert Authentik die Anmeldung des
Outposts):
```bash
kubectl -n authentik get deploy authentik-server \
-o jsonpath='{.spec.template.spec.containers[0].image}'; echo
# -> ghcr.io/goauthentik/server:2024.x.y --> merke dir 2024.x.y
```
**2.2 — Outpost anlegen:** Authentik-Admin → *Applications → **Outposts*** (die
Liste der Outposts) → **Create**.
⚠️ **Nicht „Outpost-Integrationen".** Das ist ein *anderer* Menüpunkt: dort legt
man eine Docker-/K8s-**Service-Verbindung** an, über die Authentik einen Outpost
selbst ausrollt — der Dialog „Neue Outpost-Integration" bietet deshalb nur
*Docker* oder *Kubernetes* und lässt sich nicht leer lassen. **Brauchen wir nicht**
(Prod würde sonst in Overminds Docker greifen). Wenn du dort gelandet bist:
Abbrechen und in *Outposts* wechseln.
Im **Outpost**-Formular:
- **Name**: `wiki-forward-auth`
- **Type**: `Proxy`
- **Integration**: das **Dropdown auf „No integration"/leer stehen lassen** (der
Standard) — der Container läuft extern auf Overmind (Teil 3) und meldet sich per
Token zurück. Nur wenn du dieses Feld auf Docker/K8s stellst, verlangt es eine
Service-Verbindung.
- **Applications**: **ThreadNet Wiki** auswählen.
- *Advanced settings → `authentik_host`*: `https://auth.axion1337.chat` (die
URL, die der Container **und** der Browser fürs Login erreichen).
**2.3 — Token abgreifen:** beim neuen Outpost auf *View Deployment Info* (bzw.
*Directory → Tokens*, Eintrag `ak-outpost-<id>-api`) → **Token kopieren**. Dieser
Wert wird in Teil 3 als `WIKI_OUTPOST_TOKEN` gesetzt — sonst nirgends hin, nicht
loggen, nicht committen.
**2.4 — Wer rein darf:** *Directory → Groups → `wiki-zugang`* (vom Blueprint
angelegt) → die internen Nutzer hinzufügen. Wer nicht drin ist, bekommt nach dem
Login **403**.
## Teil 3 — Overmind: Outpost-Container + Traefik (`git.lab/homelab/wiki`)
Beides gehört in den Dokploy-Stack des Wikis. **Kernpunkte zuerst**, dann das
Fragment:
- **Gemeinsames Netz.** Outpost- und Wiki-Container müssen im selben von Traefik
beobachteten Docker-Netz liegen (bei Dokploy i. d. R. `dokploy-network`) — sonst
findet die `forwardAuth`-Adresse den Outpost nicht.
- **Version pinnen** auf die aus 2.1 ermittelte (`proxy:2024.x.y` == `server`).
- **`AUTHENTIK_INSECURE: "false"`** — `auth.axion1337.chat` hat ein gültiges
öffentliches Zertifikat; kein Lab-CA-Trust nötig, weil der Outpost *nach Hetzner*
spricht, nicht ins Lab.
- **Der `/outpost.goauthentik.io/`-Router bekommt die Auth-Middleware NICHT**
sonst schützt sich der Login-Callback selbst aus und es entsteht eine
Redirect-Schleife.
- **Das Token** kommt als Dokploy-Environment/Secret `WIKI_OUTPOST_TOKEN`, nicht
im Klartext in die committete Compose-Datei.
```yaml
services:
# 1) Der Proxy-Outpost — er beantwortet Traefiks forwardAuth-Frage.
authentik-proxy:
image: ghcr.io/goauthentik/proxy:2024.x.y # == laufende Authentik-Version (2.1)
restart: unless-stopped
environment:
AUTHENTIK_HOST: https://auth.axion1337.chat
AUTHENTIK_INSECURE: "false"
AUTHENTIK_TOKEN: ${WIKI_OUTPOST_TOKEN} # aus Teil 2.3 (Dokploy-Secret)
networks: [dokploy-network]
labels:
traefik.enable: "true"
traefik.docker.network: dokploy-network
# Router NUR für den Outpost-Callback-Pfad — OHNE Auth-Middleware:
traefik.http.routers.wiki-authentik.rule: "Host(`axionwiki.lab`) && PathPrefix(`/outpost.goauthentik.io/`)"
traefik.http.routers.wiki-authentik.entrypoints: websecure
traefik.http.routers.wiki-authentik.tls: "true"
traefik.http.routers.wiki-authentik.service: wiki-authentik
traefik.http.services.wiki-authentik.loadbalancer.server.port: "9000"
# 2) Der bestehende Docusaurus-Service — nur um die Auth-Middleware erweitert.
wiki:
# ... bestehendes image/build/volumes ...
networks: [dokploy-network]
labels:
traefik.enable: "true"
traefik.docker.network: dokploy-network
traefik.http.routers.wiki.rule: "Host(`axionwiki.lab`)"
traefik.http.routers.wiki.entrypoints: websecure
traefik.http.routers.wiki.tls: "true"
traefik.http.routers.wiki.middlewares: "wiki-auth@docker"
# Die forwardAuth-Middleware:
traefik.http.middlewares.wiki-auth.forwardauth.address: "http://authentik-proxy:9000/outpost.goauthentik.io/auth/traefik"
traefik.http.middlewares.wiki-auth.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.wiki-auth.forwardauth.authResponseHeaders: "X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version"
networks:
dokploy-network:
external: true
```
**Ablauf einer Anfrage** (zum Nachvollziehen beim Debuggen):
1. Browser → `axionwiki.lab`; Traefik ruft `wiki-auth``authentik-proxy:9000/.../auth/traefik`.
2. Keine Sitzung → Outpost antwortet 302 auf `auth.axion1337.chat` (Login).
3. Nach Login kommt der Browser auf `axionwiki.lab/outpost.goauthentik.io/callback`
zurück (der Router aus Service 1, **ohne** Middleware), Outpost setzt das Cookie.
4. Erneuter Request trägt das Cookie → `auth/traefik` gibt 200 + die
`X-authentik-*`-Header → Traefik reicht an Docusaurus durch.
## Abhängigkeiten
- **#0024 (Wiki-Hostname)** — **entschieden: `axionwiki.lab`** (2026-08-12).
`external_host`, der Traefik-`Host()` und der Cookie-Scope zeigen alle darauf.
- **#0018 (Wiki-Rollout)** — sollte abgeschlossen sein, bevor das Tor davor kommt.
- **Site-to-Site-VPN** — die Login-Umleitung und die Outpost-Konfigsync brauchen
`auth.axion1337.chat` erreichbar; interne Nutzer im Lab/über VPN erreichen beides.
## Verifikation
1. `curl -sI https://axionwiki.lab` (unauthentifiziert) → **302** auf
`auth.axion1337.chat`.
2. Login als `wiki-zugang`-Mitglied → Wiki lädt.
3. Login als Nicht-Mitglied → **403** (Policy greift).
4. Outpost-Gesundheit: im Authentik-Admin zeigt der Outpost `wiki-forward-auth`
**grün/last seen** und die passende Version.
5. Resilienz-Gegenprobe: `auth.axion1337.chat` kurz nicht erreichbar → bestehende
Sitzung liest weiter (lokaler Outpost), nur neuer Login blockiert.
## Fehlerbild
- **„Anfrage wurde verweigert — Regelwerk Bindung … resultiert in False"** nach
dem Login: Du bist **nicht in `wiki-zugang`** (Schritt 2.4 übersprungen). Das
`policy=None` in der Meldung ist normal — es ist eine *Gruppen*-Bindung. Fix:
*Directory → Groups → wiki-zugang* → Nutzer hinzufügen, neu einloggen.
- **Redirect-Schleife**: der `/outpost.goauthentik.io/`-Router hat versehentlich
die Auth-Middleware (Teil 3) — entfernen.
- **Outpost bleibt in Authentik „offline"/rot**: Version des `proxy`-Containers
passt nicht zur Server-Version (2.1) oder Token/`AUTHENTIK_HOST` falsch.
## Was hier bewusst offen bleibt
- **Nichts ist live geschaltet** — der Blueprint liegt als Vorlage hier, nicht in
der angewandten ConfigMap; der Outpost-Token ist sorbs Schritt.
- **Kein Bereichs-Schutz** — Grenze von Docusaurus + Forward-Auth.
- **Zwischenstand** — siehe „Nur für die Entwicklung" oben: Umzug in die ThreadNet
Server Suite und die breitere Oberflächen-Evaluation stehen noch aus.
@@ -0,0 +1,155 @@
# Wiki.js: Zugang (wiki.axion1337.chat) + Authentik-OIDC
**Status**: vorbereitet, **nicht** ausgerollt · gehört zu #0048 (Ingress/Cert) und
#0049 (OIDC) · **löst den Forward-Auth-Zwischenstand aus Guide 09 ab** (der war nur
für das statische Docusaurus nötig)
## Wie das Wiki aufgerufen wird
**Anwender und Admin öffnen dieselbe URL: `https://wiki.axion1337.chat`.** Wiki.js
zeigt seine eigene Login-Seite → „Login with Authentik" → Redirect zu
`auth.axion1337.chat` → zurück. **Natives OIDC, kein Forward-Auth/Outpost**
Wiki.js authentifiziert selbst. Nach dem Login entscheidet die Authentik-Gruppe
über Sicht und Bearbeiten (Rollen/Abschottung, #0049). Öffentlich wie der Rest der
Plattform; Abschottung über Login + Rolle, nicht übers Netz.
## Teil 1 — Ingress + Zertifikat (`apps/production/wiki-ingress.yaml`)
Muster: `apps/authentik/certificate.yaml` + `apps/authentik/ingress.yaml`.
Namespace = der der Wiki.js-Deployment (#0048); hier als `matrix` angenommen.
Service-Name/Port (`wikijs:3000`) müssen zum Deployment passen.
```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wiki-axion1337-chat-cert
namespace: matrix
spec:
secretName: wiki-axion1337-chat-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- wiki.axion1337.chat
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: wikijs
namespace: matrix
spec:
entryPoints: [websecure]
tls:
secretName: wiki-axion1337-chat-tls
routes:
- match: Host(`wiki.axion1337.chat`)
kind: Rule
services:
- name: wikijs
port: 3000
```
Neue Datei in die Kustomization des `production`-Ordners aufnehmen. DNS: A-Record
`wiki.axion1337.chat → 49.13.132.245` ist von sorb angelegt (Propagation läuft).
## Teil 2 — Authentik-OIDC-Provider (Blueprint)
Als Dokument in `apps/authentik/authentik-blueprints.yaml`, Muster wie
`matrix-oidc-provider`. **Bewusst eigener Name/Slug** (`wiki-js`), damit es
**parallel** zum Guide-09-Proxy-Provider „ThreadNet Wiki"/App `wiki` läuft; beim
Umschalten auf die Suite werden die Guide-09-Objekte (Proxy-Provider, App `wiki`,
Outpost, ggf. Gruppe `wiki-zugang`) **stillgelegt**.
```yaml
wiki-oidc-provider.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: wiki-oidc-provider
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers:
name: ThreadNet Wiki (Wiki.js)
id: wiki_oidc_provider
attrs:
client_type: confidential
client_id: !Env AUTHENTIK_WIKI_OIDC_CLIENT_ID
client_secret: !Env AUTHENTIK_WIKI_OIDC_CLIENT_SECRET
# Aus Wiki.js übernehmen (Teil 3 zeigt die Callback-URL nach Anlegen
# der Strategy). Erst danach hier eintragen:
redirect_uris:
- matching_mode: strict
url: https://wiki.axion1337.chat/login/PROVIDERKEY/callback
sub_mode: hashed_user_id
include_claims_in_id_token: true
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
property_mappings:
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-openid"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-email"]]
- !Find [authentik_core.propertymapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"]]
# + Gruppen-Claim: entweder das mitgelieferte Groups-Scope-Mapping
# (falls vorhanden) oder ein eigenes Scope-Mapping, das "groups"
# zurückgibt — nötig für Rollen/Abschottung (#0049).
- model: authentik_core.application
state: present
identifiers:
slug: wiki-js
attrs:
name: ThreadNet Wiki
provider: !KeyOf wiki_oidc_provider
meta_description: Internes Wiki (Betrieb/Anwender), Zugriff nach Gruppe
policy_engine_mode: any
open_in_new_tab: false
```
`client_id`/`client_secret` sind Credentials → in die `authentik-credentials`-
Secret (wie `AUTHENTIK_MAS_OIDC_CLIENT_SECRET`), **nicht** in die ConfigMap. Der
ConfigMap-Blueprint liest sie per `!Env`.
## Teil 3 — Wiki.js-Seite (Authentication → OpenID Connect)
In Wiki.js *Administration → Authentication → + OpenID Connect*:
- **Client ID / Client Secret**: dieselben Werte wie in Teil 2.
- **Authorization Endpoint URL**: `https://auth.axion1337.chat/application/o/authorize/`
- **Token Endpoint URL**: `https://auth.axion1337.chat/application/o/token/`
- **User Info Endpoint URL**: `https://auth.axion1337.chat/application/o/userinfo/`
- **Issuer**: `https://auth.axion1337.chat/application/o/wiki-js/`
- **Logout URL**: `https://auth.axion1337.chat/application/o/wiki-js/end-session/`
- **Scopes**: `openid email profile` (+ das Groups-Scope aus Teil 2).
- **Groups Claim**: `groups` → auf Wiki.js-Gruppen/Rollen mappen (Admin schreibt,
Anwender read-only, Betrieb/Anwender abgeschottet — #0049).
⚠️ **Reihenfolge:** Wiki.js zeigt beim Anlegen der Strategy die **Callback-URL**
(`https://wiki.axion1337.chat/login/<providerKey>/callback`). Diese `<providerKey>`
in die `redirect_uris` von Teil 2 eintragen — vorher passt die Umleitung nicht.
## Reihenfolge / Abhängigkeiten
1. Wiki.js-Deployment + Service `wikijs` (#0048) — sonst zeigt die IngressRoute
ins Leere.
2. Ingress + Cert (Teil 1) — Cert wird erst ausgestellt, wenn der ACME-Challenge
über den Ingress erreichbar ist.
3. OIDC (Teil 2/3) — Callback-URL aus Wiki.js, dann Blueprint scharf.
4. Rollen/Abschottung + Gruppen (#0049) — Gruppen legt sorb an.
## Braucht sorb
- **OIDC Client-ID + Secret** (Credential; in `authentik-credentials`).
- **Authentik-Gruppen** für #0049 (Admin/Betrieb/Anwender).
- Beim Cutover: die **Guide-09-Objekte stilllegen** (Proxy-Provider „ThreadNet
Wiki", App `wiki`, Outpost `wiki-forward-auth`).
## Verifikation
1. `dig wiki.axion1337.chat``49.13.132.245`.
2. `curl -sI https://wiki.axion1337.chat` → 200/302 (nicht Cert-Fehler).
3. Aufruf → Wiki.js-Login → „Login with Authentik" → `auth.axion1337.chat`
zurück, eingeloggt.
4. **Admin**: editieren, sieht Betrieb + Anwender. **Anwender**: read-only,
`/betrieb` nicht in Navigation/Suche.
+34 -1
View File
@@ -9,10 +9,15 @@ Die Implementierungen wurden in dieser Reihenfolge durchgeführt. Für neue Setu
| # | Titel | Datei | Status | Zieldomäne |
|---|-------|-------|--------|-----------|
| 1 | TURN Server für WebRTC Video-Calls | `01-turn-server-setup.md` | ✅ Deployed | `turn.axion1337.chat` |
| 2 | Authentik als Identity Provider | `02-authentik-identity-provider.md` | ✅ Stage 1 Deployed | `auth.axion1337.chat` |
| 2 | Authentik als Identity Provider | `02-authentik-identity-provider.md` | ✅ Deployed | `auth.axion1337.chat` |
| 3 | Monitoring mit Alloy/Prometheus/Loki | `03-monitoring-integration.md` | ✅ Deployed | lokal (10.0.0.3) |
| 4 | Element Web Anpassung & Desktop-Apps | `04-element-customization.md` | ✅ Deployed | `axion1337.chat` |
| 5 | Room Policies (Retention, Publication, Auto-Join) | `05-room-policies.md` | ✅ Deployed | Matrix Synapse |
| 6 | Moderationsbot (Draupnir) & Content Scanning | `06-moderation-content-scanning.md` | ✅ Deployed | Matrix Synapse |
| 7 | Host-Wartungsbenachrichtigungen (unattended-upgrades) | `07-host-maintenance-notifications.md` | ✅ Deployed | Host-Ebene (kein K8s) |
| 8 | @concierge — Gäste-Einladungen mit Ablauf | `08-concierge-gaeste-einladungen.md` | ⏳ Wartet auf Zugangsdaten | Matrix Synapse |
| 9 | Docusaurus-Wiki hinter Authentik (Forward-Auth) | `09-wiki-forward-auth.md` | 📝 Vorbereitet, nicht ausgerollt · abgelöst durch 10 (Suite) | Authentik + Traefik (Overmind) |
| 10 | Wiki.js: Zugang (wiki.axion1337.chat) + Authentik-OIDC | `10-wiki-js-zugang-und-oidc.md` | 📝 Vorbereitet, nicht ausgerollt | Authentik + Traefik (Suite/k8s) |
---
@@ -85,6 +90,34 @@ Custom Themes, Desktop-Setup-Scripts, Element Admin.
### [05-room-policies.md](05-room-policies.md)
Message Retention, Room Publication, Auto-Join Policies.
### [06-moderation-content-scanning.md](06-moderation-content-scanning.md)
Draupnir Moderationsbot (Bans, Policy-Listen), Content Scanner via eigenes Synapse-Modul für
unverschlüsselte Räume UND client-seitiges Scanning für verschlüsselte Räume/DMs (Issue #19 +
Erweiterung) - inkl. Electron/Desktop-Deckungslücke (Issue #44). Beide live getestet.
### [07-host-maintenance-notifications.md](07-host-maintenance-notifications.md)
Erster nicht-GitOps-verwalteter Mechanismus im Repo: systemd-Timer auf dem nackten Host meldet
per Mail + Matrix-Thread-Reply anstehende `unattended-upgrades`, bevor sie laufen (Issue #24).
### [08-concierge-gaeste-einladungen.md](08-concierge-gaeste-einladungen.md)
@concierge-Bot: Einladungslinks mit 3-Tage-Ablauf, begrenzte Selbstverlängerung, dauerhafte
Freischaltung nur durch Admin-Kommando im Matrix-Raum (Issue #48). Deployt, wartet auf
Zugangsdaten (Matrix-Konto, Authentik-Token, Secret).
### [09-wiki-forward-auth.md](09-wiki-forward-auth.md)
Statisches Docusaurus-Wiki hinter Authentik: Proxy-Provider (Forward-Auth) + Anwendung +
Gruppe `wiki-zugang` als Blueprint, Proxy-Outpost-Container plus Traefik-Middleware auf
Overmind. Vorbereitet, nicht ausgerollt — Blueprint als Vorlage im Guide, Outpost-Token
und `wiki-zugang`-Mitglieder sind sorbs Schritt. **Entwicklungs-Zwischenstand**, wird beim
Suite-Umzug durch Guide 10 (Wiki.js, natives OIDC) abgelöst — ADR-0014.
### [10-wiki-js-zugang-und-oidc.md](10-wiki-js-zugang-und-oidc.md)
Zugang zum Suite-Wiki (Wiki.js, ADR-0014): öffentlich `wiki.axion1337.chat` mit
**nativem** Authentik-OIDC (kein Forward-Auth). Fertiges Ingress+Certificate-Manifest
(Muster wie authentik), Authentik-OIDC-Provider-Blueprint und die Wiki.js-OIDC-Einstellungen.
Vorbereitet, nicht ausgerollt — Client-Secret und Gruppen sind sorbs Schritt; gehört zu
#0048 (Ingress/Cert) und #0049 (OIDC/Rollen).
---
## 🛠️ Wartung & Troubleshooting
Executable → Regular
+28 -1
View File
@@ -103,4 +103,31 @@ ist reines Zurückkopieren, kein Neu-Erzeugen).
`sops-age`-Secret selbst (auf demselben Server) und lokal bei wem auch immer ihn zuletzt
zurückgeholt hat. Es gibt kein separates, offsite Backup. Fällt der Server komplett aus
(nicht nur der lokale Rechner), sind alle SOPS-verschlüsselten Secrets im Repo unlesbar.
Siehe Issue-Backlog für die Entscheidung, ob/wie das abgesichert wird.
Siehe Issue-Backlog für die Entscheidung, ob/wie das abgesichert wird.
---
## 🌐 Node-Konfiguration: `/etc/hosts`-Eintrag für den Gitea-Host
Der K3s-Node und der Gitea-Host (`rohana.axion1337.de`, Container-Registry + Git-Remote)
teilen sich ein privates Hetzner-Netzwerk (Node `10.0.0.2`, Gitea-Host `10.0.0.3`). Seit
2026-07-28 hat der Node dafür einen manuellen `/etc/hosts`-Eintrag:
```
10.0.0.3 rohana.axion1337.de
```
**Warum**: eine Firewall-Fehlkonfiguration hatte den Node zeitweise komplett von
`rohana.axion1337.de` über die öffentliche IP abgeschnitten, was Image-Pulls (z.B. für
Custom-Images wie `sorb/axion-backup`) mit Timeout scheitern ließ. Der Eintrag macht
Image-Pulls unabhängig vom Zustand der öffentlichen Firewall.
**Wichtig**: Das ist unmanaged Node-Konfiguration (kein GitOps, kein Kubernetes-Objekt) —
überlebt einen Node-Neuaufbau **nicht** und muss dann erneut gesetzt werden:
```bash
echo "10.0.0.3 rohana.axion1337.de" | sudo tee -a /etc/hosts
```
Ein sauberer, clusterweiter Ersatz (z.B. CoreDNS-Rewrite, damit auch Pods selbst intern
auflösen) ist als Issue #41 nachgehalten.
View File
@@ -0,0 +1,93 @@
# Calls scheitern still, obwohl Login und Messaging laufen
**Symptom:** Ein Nutzer kann sich anmelden und Nachrichten schreiben, aber **kein
Anruf kommt zustande**. Beim Klick auf „Anruf" passiert serverseitig *nichts*
kein `openid/request_token`, kein `call.member`-Event, keine Fehlermeldung. Das
Element-Call-Widget-iframe bleibt leer bzw. lädt ewig.
Zuerst live gesehen bei `@apo` am 2026-08-11 (Vor-Authentik-Konto, durch mehrere
Identitäts-Resets gegangen).
## Grundursache
Dem Konto fehlt seine Zeile in Synapses **`profiles`-Tabelle**. Jeder registrierte
Nutzer hat eine; Deaktivieren löscht sie, Reaktivieren legt sie **nicht** neu an.
Ein Konto ohne diese Zeile läuft für Messaging weiter, aber:
1. `PUT /_matrix/client/v3/profile/<user>/displayname` crasht mit
**`TypeError: 'NoneType' object is not subscriptable`** in
`_check_profile_size` (`synapse/storage/databases/main/profile.py`). Die
Größenprüfung macht `txn.fetchone()` und indiziert das Ergebnis — bei fehlender
Zeile ist das `None`. Der Client meldet „Anzeigename konnte nicht gesetzt
werden". *(Synapse-Bug: der Code setzt die Zeile als immer vorhanden voraus.)*
2. Dadurch bleibt der Displayname leer — global **und** in der Raum-Mitgliedschaft.
3. Element Web baut die Element-Call-Widget-URL u. a. aus dem Displaynamen. Fehlt
er überall, initialisiert das **Widget-iframe nie** (meldet nie `ContentLoaded`,
in der Browser-Konsole als „Messaging present but not yet started" sichtbar).
4. Ohne aktives Widget wird nie ein OpenID-Token angefragt, also nie die SFU
erreicht — der ganze Call-Pfad bleibt stumm.
## Diagnose (read-only, in dieser Reihenfolge)
```bash
# 1) Das Alarmsignal: ein sonst aktiver Nutzer OHNE OpenID-Token.
# open_id_tokens werden nicht geprunt — wer je telefoniert hat, hat Einträge.
kubectl exec -n matrix matrix-stack-postgres-0 -c postgres -- \
psql -U postgres -d synapse -At -c \
"SELECT count(*) FROM open_id_tokens WHERE user_id='@NAME:axion1337.chat'"
# -> 0 bei einem aktiven Konto ist verdächtig.
# 2) Die Bestätigung: fehlt die profiles-Zeile?
kubectl exec -n matrix matrix-stack-postgres-0 -c postgres -- \
psql -U postgres -d synapse -At -c \
"SELECT count(*) FROM profiles WHERE full_user_id='@NAME:axion1337.chat'"
# -> 0 = das ist die Ursache.
# 3) Confounder ausschließen: ist der Testraum verschlüsselt?
# Wenn NICHT, ist Krypto/Cross-Signing als Ursache raus.
kubectl exec -n matrix matrix-stack-postgres-0 -c postgres -- \
psql -U postgres -d synapse -At -c \
"SELECT ev.type FROM current_state_events cse JOIN events ev ON ev.event_id=cse.event_id
WHERE cse.room_id='!RAUM:axion1337.chat' AND ev.type='m.room.encryption'"
```
Optional der harte Beleg im Log (der Displayname-500 erscheint beim Setzversuch):
```bash
kubectl logs -n matrix matrix-stack-synapse-main-0 --tail=3000 | grep -A15 ProfileFieldRestServlet
```
## Fix
Die fehlende Registrierungs-Default-Zeile nachtragen. **Nur `user_id` (Localpart)
und `full_user_id` sind nötig, der Rest bleibt NULL** — an gesunden Konten
(`clark`, `calltest01`) gegengeprüft. Vorher immer die (fehlende) Zeile anzeigen,
nichts überschreiben:
```bash
kubectl exec -n matrix matrix-stack-postgres-0 -c postgres -- \
psql -U postgres -d synapse -c \
"INSERT INTO profiles (user_id, full_user_id)
VALUES ('NAME','@NAME:axion1337.chat') ON CONFLICT (user_id) DO NOTHING
RETURNING *;"
```
Danach: der Nutzer setzt im Client seinen Anzeigenamen (funktioniert jetzt),
`open_id_tokens` füllt sich beim nächsten Call-Versuch, der Anruf läuft.
## Verifikation
```bash
# displayname gesetzt, open_id_tokens > 0, call.member vorhanden
kubectl exec -n matrix matrix-stack-postgres-0 -c postgres -- psql -U postgres -d synapse -At -c \
"SELECT (SELECT displayname FROM profiles WHERE full_user_id='@NAME:axion1337.chat'),
(SELECT count(*) FROM open_id_tokens WHERE user_id='@NAME:axion1337.chat')"
```
## Wen es betrifft
Nur Konten **ohne** `profiles`-Zeile — praktisch nur alte, mehrfach
zurückgesetzte/deaktivierte-und-reaktivierte Konten. Frisch über den
Einladungs-Flow provisionierte Konten bekommen den Displaynamen aus Authentiks
`{{ user.name }}`-Claim und haben die Zeile immer. Am 2026-08-11 war `@apo` der
einzige betroffene *lebende* Nutzer (`frank` hatte seine Zeile;
`crank`/`shank`/`stank` sind deaktiviert).
Executable → Regular
+12 -2
View File
@@ -63,6 +63,15 @@ Dieser Ordner enthält detaillierte Troubleshooting- und Reparaturanleitungen f
---
### 5. **CALLS-FEHLEN-PROFILE-ZEILE.md**
**Für**: Konto kann sich anmelden und schreiben, aber **kein Anruf** kommt zustande
**Wann**: Call-Klick löst serverseitig nichts aus (kein `openid/request_token`, kein `call.member`); Anzeigename lässt sich nicht setzen
**Root Cause**: Fehlende `profiles`-Zeile in Synapse → Profil-Schreibpfad crasht (`_check_profile_size`, NoneType) → kein Displayname → Element-Call-Widget initialisiert nie
**Lösung**: Fehlende Registrierungs-Default-Zeile nachtragen (read-only Diagnose, dann ein `INSERT`)
**Status**: **Resolved (2026-08-11)**`@apo` (Vor-Authentik-Konto nach mehreren Resets), einziger betroffener lebender Nutzer
---
## 🎯 Schneller Einstieg
### Szenario 1: "Enrollment funktioniert nicht, ich weiß nicht warum"
@@ -85,9 +94,10 @@ Dieser Ordner enthält detaillierte Troubleshooting- und Reparaturanleitungen f
| Problem | Nutzer | Guide | Status |
|---------|--------|-------|--------|
| Nur Standard Enrollment funktioniert | akadmin ✅ | - | Resolved |
| User nur in Authentik, nicht in Synapse | Boje | `DIAGNOSTIK-AUTHENTIK-FLOW.md` | In Progress |
| User nur in Authentik, nicht in Synapse | Boje | `DIAGNOSTIK-AUTHENTIK-FLOW.md` | **Resolved (2026-07-27)** — identischer Root Cause wie bei Klaus (fehlende Write/Password/Login-Stages im `matrix-invitation`-Flow), behoben durch denselben Issue-#7-Fix. Nicht erneut mit Boje selbst nachgetestet, aber mit anderen Test-Usern (`clark`, `lucky`) end-to-end verifiziert - der zugrundeliegende Flow ist jetzt für jeden Nutzer korrekt. |
| Einladungslink-Fehler: "kein ausstehender benutzer" | Klaus | `AUTHENTIK-CREATE-INVITATION-FLOW.md` | **Fixed (2026-07-27)**`matrix-invitation` Flow hatte nur Invite+Prompt Stage-Bindings, beide auf `order=0`. Write/Password/Login-Stages fehlten komplett. Live gefixt + als Blueprint (`apps/authentik/authentik-blueprints.yaml`) reproduzierbar gemacht. |
| OIDC-Integration unklar | General | `AUTHENTIK-FIX-TEMPLATE.md` | Reference |
| Calls scheitern still, Login/Messaging läuft | apo | `CALLS-FEHLEN-PROFILE-ZEILE.md` | **Resolved (2026-08-11)** — fehlende `profiles`-Zeile nachgetragen, an `clark`/`calltest01` gegengeprüft; open_id_tokens 0→6, Call verifiziert |
---
@@ -108,5 +118,5 @@ Dieser Ordner enthält detaillierte Troubleshooting- und Reparaturanleitungen f
---
**Zuletzt aktualisiert**: 2026-05-18
**Zuletzt aktualisiert**: 2026-07-30
**Verfasser**: Claude Code + Thore
@@ -0,0 +1,28 @@
# Example for /etc/maintenance-notify/config (host-level, NOT deployed via
# GitOps/Flux - copy manually to the target host and adjust for your own
# instance). Sourced as a plain bash file by maintenance-notify.sh.
#
# The values below are axion1337.chat's own, real configuration - shown as a
# concrete worked example. Replace every value for your own homeserver/room.
# Your homeserver's base URL (matrix client-server API). This is often a
# dedicated subdomain, NOT your apex domain - check your own
# .well-known/matrix/client delegation (`m.homeserver.base_url`) to be sure.
# For axion1337.chat specifically it's matrix.axion1337.chat, not the apex.
MATRIX_HOMESERVER="https://matrix.axion1337.chat"
# The room the notification gets posted into.
MATRIX_ROOM_ID="!lmZaajvVboTPxQxXzv:axion1337.chat"
# The thread to reply into (matrix.to link's event id after the room id).
# IMPORTANT: Matrix event IDs start with "$" - this value MUST be
# single-quoted, otherwise bash will try to expand "$T3MQZgf..." as a
# variable and silently truncate it to an empty string.
MATRIX_THREAD_EVENT_ID='$T3MQZgf-maQwfshCKlCn0bo4DGHn4sZS-8eI9u2V6ZI'
# Sending identity - must match the "user"/"from" in /etc/msmtprc.
MAIL_FROM="wartung@axion1337.chat"
# Where the pre-update heads-up actually lands (your everyday inbox, not
# necessarily the sending mailbox above).
MAIL_TO="your-address@example.com"
@@ -0,0 +1,8 @@
[Unit]
Description=Pre-update maintenance notification (Mail + Matrix)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/maintenance-notify.sh
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# Pre-update maintenance notification. Runs as a systemd oneshot service, well
# before apt-daily-upgrade.timer's own window, and tells you (Mail + Matrix)
# which packages are about to be auto-upgraded - so a post-update problem can
# immediately be traced back to "the update ran, that's probably it".
#
# Generic by design: no instance-specific values are hardcoded here. All of
# them live in /etc/maintenance-notify/config - see config.example in this
# same directory, and docs/deployment-guides/07-host-maintenance-notifications.md
# for the full setup guide.
set -euo pipefail
CONFIG_FILE="/etc/maintenance-notify/config"
MATRIX_TOKEN_FILE="/etc/maintenance-notify/matrix-token"
if [ ! -f "$CONFIG_FILE" ]; then
echo "Missing $CONFIG_FILE - see docs/deployment-guides/07-host-maintenance-notifications.md" >&2
exit 1
fi
# shellcheck source=/dev/null
. "$CONFIG_FILE"
: "${MATRIX_HOMESERVER:?MATRIX_HOMESERVER not set in $CONFIG_FILE}"
: "${MATRIX_ROOM_ID:?MATRIX_ROOM_ID not set in $CONFIG_FILE}"
: "${MATRIX_THREAD_EVENT_ID:?MATRIX_THREAD_EVENT_ID not set in $CONFIG_FILE}"
: "${MAIL_FROM:?MAIL_FROM not set in $CONFIG_FILE}"
: "${MAIL_TO:?MAIL_TO not set in $CONFIG_FILE}"
apt-get update -qq
DRYRUN_OUTPUT="$(unattended-upgrade --dry-run -v 2>&1)"
# Exact log strings taken from /usr/bin/unattended-upgrade itself (verified
# live on the target host), not guessed - this is the one message emitted
# when there is nothing to do, and the one line emitted with the package
# list otherwise. They're mutually exclusive.
if echo "$DRYRUN_OUTPUT" | grep -q "No packages found that can be upgraded unattended"; then
echo "No pending upgrades - nothing to notify."
exit 0
fi
PENDING_PKGS="$(echo "$DRYRUN_OUTPUT" | sed -n 's/^.*Packages that will be upgraded: //p' | tail -1)"
if [ -z "$PENDING_PKGS" ]; then
echo "No pending upgrade packages parsed - nothing to notify."
exit 0
fi
HOST_LABEL="$(hostname -f 2>/dev/null || hostname)"
NOW="$(date '+%Y-%m-%d %H:%M %Z')"
BODY="Host: ${HOST_LABEL}
Zeitpunkt: ${NOW}
Im naechsten apt-daily-upgrade.timer-Fenster werden folgende Pakete automatisch aktualisiert:
${PENDING_PKGS}
Automatische Vorab-Benachrichtigung, keine Aktion erforderlich."
send_mail() {
if ! command -v msmtp >/dev/null 2>&1; then
echo "msmtp not installed, skipping mail notification" >&2
return 1
fi
{
echo "From: ${MAIL_FROM}"
echo "To: ${MAIL_TO}"
echo "Subject: [${HOST_LABEL}] Anstehendes Update"
echo
echo "$BODY"
} | msmtp -a maintenance-notify -- "${MAIL_TO}"
}
send_matrix() {
if [ ! -f "$MATRIX_TOKEN_FILE" ]; then
echo "Missing $MATRIX_TOKEN_FILE, skipping Matrix notification" >&2
return 1
fi
local token txn_id encoded_room payload
token="$(cat "$MATRIX_TOKEN_FILE")"
txn_id="$(uuidgen)"
encoded_room="$(jq -rn --arg s "$MATRIX_ROOM_ID" '$s|@uri')"
payload="$(jq -n --arg body "$BODY" --arg event_id "$MATRIX_THREAD_EVENT_ID" \
'{msgtype: "m.text", body: $body, "m.relates_to": {rel_type: "m.thread", event_id: $event_id}}')"
curl -sS -f -X PUT \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
--data "$payload" \
"${MATRIX_HOMESERVER}/_matrix/client/v3/rooms/${encoded_room}/send/m.room.message/${txn_id}" \
> /dev/null
}
mail_ok=0
matrix_ok=0
send_mail && mail_ok=1
send_matrix && matrix_ok=1
if [ "$mail_ok" -eq 0 ] && [ "$matrix_ok" -eq 0 ]; then
echo "Both mail and Matrix notification failed" >&2
exit 1
fi
@@ -0,0 +1,15 @@
[Unit]
Description=Daily pre-update maintenance notification, fires before apt-daily-upgrade.timer's window
[Timer]
# Fixed, unrandomized time - must fire safely before the earliest possible
# start of apt-daily-upgrade.timer. Default apt-daily-upgrade.timer ships as
# OnCalendar=*-*-* 6:00 with RandomizedDelaySec=60m (actual run: 06:00-07:00).
# If your apt-daily-upgrade.timer differs (check with
# `systemctl cat apt-daily-upgrade.timer`), adjust the time below to keep a
# comfortable lead.
OnCalendar=*-*-* 05:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,24 @@
# Template for /etc/msmtprc (host-level, NOT deployed via GitOps/Flux - copy
# manually to the target host and fill in the placeholders yourself).
#
# Copy to /etc/msmtprc, replace the __PLACEHOLDER__ values below with your own
# transactional-mail provider's SMTP details, then:
# chmod 600 /etc/msmtprc
# The password itself is NOT stored here - it's read at send-time from
# /etc/maintenance-notify/mail-password (chmod 600, plain text, one line, no
# trailing newline needed either way).
account maintenance-notify
host __SMTP_HOST__
port __SMTP_PORT__
tls on
# Port 465 = implicit TLS (tls_starttls off, as below). If your provider uses
# port 587/STARTTLS instead, flip this to "tls_starttls on".
tls_starttls off
auth on
user __MAIL_FROM__
passwordeval "cat /etc/maintenance-notify/mail-password"
from __MAIL_FROM__
logfile /var/log/msmtp.log
account default : maintenance-notify
@@ -0,0 +1,13 @@
# /etc/systemd/system/wg-quick@lab.service.d/10-after-docker.conf
#
# ⚠️ ABGELEITET, NICHT VOM HOST KOPIERT — vor Gebrauch gegen CFGMON abgleichen
# (siehe README, Abschnitt "Noch aus dem Live-Stand zu ergaenzen").
#
# Zweck: Der Tunnel muss NACH Docker starten. wg-quick traegt eine ACCEPT-Regel in
# die DOCKER-USER-Kette ein; startet Docker danach, baut es seine Ketten neu auf und
# die Regel ist weg — der Tunnel steht dann zwar, aber der Verkehr wird verworfen.
# Der Fehler ist unangenehm, weil `wg show` einen gesunden Handshake zeigt.
[Unit]
After=docker.service
Wants=docker.service
+70
View File
@@ -0,0 +1,70 @@
# wireguard-lab — Site-to-Site-Tunnel CFGMON ↔ Lab
Host-Konfiguration für den WireGuard-Tunnel aus
[ADR-0004](https://git.lab/axion1337.chat/management/-/blob/main/docs/adr/0004-site-to-site-vpn-hetzner-lab.md)
(Zonen: [ADR-0017](https://git.lab/axion1337.chat/management/-/blob/main/docs/adr/0017-split-dns-cfgmon-vier-zonen.md)).
Läuft **auf dem Host**, nicht als Pod — deshalb hier unter `host-config/` und nicht als
Flux-Manifest (siehe die Erklärung in der Repo-`CLAUDE.md`). Angelegt, weil diese Dateien
bisher **nur auf dem Host existierten**: ein Neuaufbau von CFGMON hätte sie aus AAR-Prosa
rekonstruieren müssen (management #0027, W4 Punkt 4).
## Was wohin gehört
| Datei hier | Ziel auf dem Host | Secret? |
|---|---|---|
| `lab.conf.example` | `/etc/wireguard/lab.conf` (Mode 0600, root) | **ja**`PrivateKey` |
| `10-after-docker.conf` | `/etc/systemd/system/wg-quick@lab.service.d/` | nein |
| — (liegt in `ci/lab-ca-chain.crt`) | `/usr/local/share/ca-certificates/axionlabs-root.crt` | nein |
**Der Private Key steht bewusst nicht im Repo.** Er wird auf dem Host eingesetzt und ist
nirgends sonst hinterlegt — geht er verloren, wird auf der UDM ein neues Keypair für den
Client „Hetzner" erzeugt und hier eingetragen. Gleiches Muster wie `maintenance-notify`
(`config.example` / `msmtprc.template`).
## Einrichten
```bash
# 1. Konfiguration ablegen und Key eintragen
sudo install -m 0600 -o root -g root lab.conf.example /etc/wireguard/lab.conf
sudo vi /etc/wireguard/lab.conf # PrivateKey + PublicKey eintragen
# 2. Start-Reihenfolge: der Tunnel muss nach Docker hoch (sonst fehlen die iptables-Ketten)
sudo mkdir -p /etc/systemd/system/wg-quick@lab.service.d
sudo cp 10-after-docker.conf /etc/systemd/system/wg-quick@lab.service.d/
sudo systemctl daemon-reload
# 3. Root-CA in den Truststore (Zertifikat liegt im Repo unter ci/)
sudo cp ../../ci/lab-ca-chain.crt /usr/local/share/ca-certificates/axionlabs-root.crt
sudo update-ca-certificates
# 4. Starten
sudo systemctl enable --now wg-quick@lab
```
## Prüfen
```bash
sudo wg show # Handshake jünger als ~2 min?
resolvectl domain # gerouteten Zonen (siehe ADR-0017)
curl -sI https://git.lab/users/sign_in | head -1 # 302 = Tunnel + DNS + CA stimmen
```
⚠️ **Nicht mit `ping` prüfen.** Durch den Tunnel sind nur HTTPS (443 auf `10.58.73.17`) und
DNS (53 auf `10.58.73.1`) offen; ICMP ist gesperrt. Ein fehlgeschlagener Ping bedeutet hier
**nicht**, dass etwas kaputt ist — das hat schon eine Session in die falsche Richtung
geschickt.
## Noch aus dem Live-Stand zu ergänzen
Diese Dateien sind aus ADR-0004/ADR-0017 abgeleitet, **nicht** vom laufenden Host kopiert
(von außerhalb ist `/etc/wireguard/` nicht lesbar). Vor dem ersten Einsatz als Vorlage
einmal gegen CFGMON abgleichen:
```bash
# auf CFGMON, Private Key geschwärzt:
sudo sed 's/^PrivateKey.*/PrivateKey = <REDACTED>/' /etc/wireguard/lab.conf
cat /etc/systemd/system/wg-quick@lab.service.d/10-after-docker.conf
```
Abweichungen hier einpflegen; danach diesen Abschnitt entfernen.
@@ -0,0 +1,39 @@
# /etc/wireguard/lab.conf auf CFGMON — Mode 0600, root:root.
#
# Werte aus ADR-0004 (Architektur v2) und ADR-0017 (Split-DNS-Zonen).
# CFGMON ist CLIENT/Initiator; die UDM ist der WireGuard-SERVER. Die Richtung ist
# umgedreht gegenueber dem urspruenglichen Entwurf, weil UniFi WireGuard nur als
# Server anbietet ("Networks Behind Client" fuer die Rueckroute).
#
# ⚠️ PrivateKey NICHT ins Repo. Beim Verlust auf der UDM ein neues Keypair fuer den
# Client "Hetzner" erzeugen und hier eintragen.
[Interface]
# Adresse dieses Clients im Tunnelnetz 10.58.75.0/24
Address = 10.58.75.2/32
PrivateKey = <PRIVATE KEY DES CLIENTS "Hetzner" — von der UDM, nie ins Repo>
# Split-DNS: nur diese Zonen gehen an den Lab-Resolver, alles andere an den
# System-Resolver. Begruendung je Zone in ADR-0017.
# ~lab -> git.lab / wiki.lab (existieren oeffentlich nicht)
# ~axionlabs.de -> ca.axionlabs.de zeigt intern auf 10.58.73.13 (Split-Horizon)
# ~axion1337.de -> git.axion1337.de existiert nur intern
# ~lab.de wurde per ADR-0017 entfernt: kein interner Name darunter, und es leitete
# eine fremde oeffentliche Domain ueber den Lab-Resolver.
DNS = 10.58.73.1
Domains = ~lab ~axionlabs.de ~axion1337.de
[Peer]
PublicKey = <PUBLIC KEY DES UDM-SERVERS — mit `wg show` AM GERAET gegenlesen>
# Bewusst eng: nur das Lab-VLAN und der Server im Tunnelnetz. KEIN 0.0.0.0/0 —
# der oeffentliche Verkehr von CFGMON darf nicht durch den Tunnel.
AllowedIPs = 10.58.73.0/24, 10.58.75.1/32
# "Alternate Address for Clients" der UDM; Port 51841 (NICHT 51820 — der Versatz
# zwischen Fritzbox-Freigabe und UDM-Port hat eine halbe Nacht gekostet).
Endpoint = 178.25.213.70:51841
# CFGMON haelt den Tunnel offen; die Lab-Seite kann ihn nicht selbst oeffnen
# (Bedarfsfall-Prinzip: der Schalter liegt in der UniFi-UI).
PersistentKeepalive = 25