Compare commits

...
258 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
Thore Cimbal 09aaf1b2b5 docs: Document age key recovery from an already-running cluster
install.md only covered generating a brand-new key during fresh setup.
Added the actual recovery path (retrieve the existing key from the
sops-age secret) - what today's session actually needed after the Mac
reinstall. Also flags the age key's single-backup-location weakness,
tracked in issue #20.
2026-07-28 12:00:00 +00:00
Thore Cimbal 337dbe85ac docs: Migrate backlog to Gitea issues, remove stale completed items
Created issues #11-#31 for the remaining backlog (VP9 retry, ThreadNet-Web
build bug, MAS template link, WAF, and 17 security/infra hardening items),
plus #13 for the previously-noted MAS custom-template idea. Removed/marked
done the stale checklist items that no longer reflected reality (Authentik
Stage 2/E2E-test/invite-links, Hetzner firewall, SSH hardening - all
already completed weeks ago). TASKS.md's detailed backlog section now
points to Gitea issues as the single source of truth instead of
duplicating tracking in two places.
2026-07-28 12:00:00 +00:00
Thore Cimbal e462980dac docs: Document simulcast middle layer and H.264 codec switch for calls 2026-07-28 12:00:00 +00:00
Thore Cimbal 79db6a8e49 feat: Try H.264 codec for calls instead of VP8
H.264 uses classic simulcast (like VP8), not SVC, so it's compatible
with the fork's simulcast-shaped publish options without needing a
code fix - unlike VP9/AV1 (see the 2026-07-28 incident notes). Also
tends to be hardware-accelerated on more devices, notably iOS/Safari.
Server-side: LiveKit has no codec restriction configured, supports
H.264 by default.
2026-07-28 12:00:00 +00:00
Thore Cimbal 25d1742d61 fix: Add 720p simulcast middle layer for smoother video quality
User reported visible artifacts/blockiness during calls. Root cause:
without an explicit simulcast_layers config, the fork defaulted to
only [180p, 360p] fallback layers below the 1440p top layer - any
minor network hiccup caused a hard cliff straight to blocky 360p
instead of a graceful step-down. Added a 720p middle rung.

VP9 retry deferred: LiveKit uses SVC for vp9/av1 instead of classic
simulcast, and the fork's buildPublishOptions() always builds
simulcast-shaped layers regardless of codec - likely the real reason
forcing vp9 broke calls entirely. Needs a code fix before retrying.
2026-07-28 12:00:00 +00:00
Thore Cimbal 736c39a413 docs: Document NetworkPolicies rollout (issue #10), incidents, OOM fix, and identity cleanup 2026-07-28 12:00:00 +00:00
Thore Cimbal 22a0823b7e fix: Raise matrixRTC authorisation service memory limit (20Mi -> 128Mi)
Chart default of 20Mi request+limit OOM-killed the service today after
~74 days of uptime (3rd OOM over its lifetime), causing intermittent
call setup failures unrelated to the NetworkPolicy work happening in
parallel. 20Mi is razor-thin for a long-running Go service; bumping to
a still-modest 64Mi/128Mi request/limit.
2026-07-28 12:00:00 +00:00
Thore Cimbal 3054037480 fix: Allow synapse-main to reach MAS's port 8080 for token introspection
Second port-config incident from this rollout: synapse calls MAS's
oauth2/introspect endpoint on port 8080 (not 8081 as assumed) for
every single authenticated request (sync, pushrules, capabilities,
etc). The rule only allowed kube-system (Traefik) on 8080, so every
authenticated API call failed with 503 "Unable to introspect the
access token" - all clients showed a lost connection. Patched live
immediately, this commit brings Git back in sync with that patch.
2026-07-28 12:00:00 +00:00
Thore Cimbal 8f1d39b7a8 feat: Default-deny ingress NetworkPolicy for matrix namespace
Ingress-only default-deny plus per-component allow rules, using named
container ports throughout (not Service ports) after the authentik
port-mismatch incident. Traefik -> element-web/element-admin/haproxy
(fronts both matrix.axion1337.chat and well-known)/MAS; synapse <-
haproxy+MAS; postgres <- synapse+MAS; RTC signalling via Traefik, RTC
media NodePorts left open to the internet by design; monitoring scrape
allows for synapse/postgres/rtc-sfu metrics; ACME solver allow. coturn
(hostNetwork) needs nothing - NetworkPolicy doesn't apply to it. Part
of issue #10.
2026-07-28 12:00:00 +00:00
Thore Cimbal edf224e450 fix: Correct authentik-server NetworkPolicy ports (container port, not Service port)
Caused a live 502 immediately after deploy: the rule allowed 80/443
(the Service's external ports), but NetworkPolicy filters on the pod's
actual container port after kube-proxy's DNAT - authentik-server's
Service maps 80->9000 and 443->9443. Confirmed root cause by suspending
Flux reconciliation (it was silently re-applying my manual test
deletions) and testing with the policies truly absent.
2026-07-28 12:00:00 +00:00
Thore Cimbal b32920c48f feat: Default-deny ingress NetworkPolicy for authentik namespace
Ingress-only default-deny (egress untouched) plus explicit allows:
Traefik (kube-system) + MAS (matrix ns) -> authentik-server on 80/443,
and Traefik -> cert-manager's ACME HTTP-01 solver pods on 8089.
authentik-postgresql already has its own Bitnami-chart-managed policy,
left alone. Part of issue #10.
2026-07-28 12:00:00 +00:00
Thore Cimbal cb2ffa6a08 docs: Update status summary, add session log, sync element-customization guide with wiki 2026-07-27 12:00:00 +00:00
Thore Cimbal 7d352fbf20 docs: Document VP9 incident and v0.2.1 fix for Element Call fork 2026-07-27 12:00:00 +00:00
Thore Cimbal 1bb1bc9610 fix: Retry Element Call fork without forced VP9 codec preference
v0.2.0 forced video_codec: vp9, which broke calls entirely (no audio/
video transmitted, despite server logs showing the codec regression
fallback to VP8 working). Root cause not fully confirmed. This build
keeps the 1440p/60fps/bitrate defaults but leaves video_codec unset
(defaults to vp8) to isolate whether the codec preference itself was
the trigger.
2026-07-27 12:00:00 +00:00
Thore Cimbal 37aea0254b revert: Roll back Element Call fork image - calls broken (no audio/video)
User reports calls transmit neither video nor audio with
v0.2.0-elementcall-mediaquality. Rolling back to known-good v0.1.0
while investigating; likely the video_codec: vp9 preference, not the
resolution/framerate bump.
2026-07-27 12:00:00 +00:00
Thore Cimbal fad91b6a05 docs: Document Element Call fork completion and threadnet-web build bug 2026-07-27 12:00:00 +00:00
Thore Cimbal af73cf770b feat: Element Call fork with 1440p/60fps + VP9 defaults
Bumps elementWeb image to a build with the embedded Element Call
widget replaced by our own fork (rohana.axion1337.de/sorb/threadnet-call,
based on emmick4/element-call:livekit / upstream PR #3736), raising
default call quality to up to 1440p/60fps camera (VP9) and 1440p/30fps
screen share. These are seeded defaults users can still adjust in
Settings, not hard-enforced caps.
2026-07-27 12:00:00 +00:00
Thore Cimbal 09e4225de5 fix: Replace broken netstat liveness probe on coturn with tcpSocket check
coturn started up cleanly every time (all TCP/UDP listeners on 3478/5349
opened fine per logs) but was killed every ~10s because the liveness
probe ran "netstat -uln", which doesn't exist in coturn/coturn:latest
(sh: netstat: not found) - 36000+ restarts over 88 days for a healthy
process. Switched to a tcpSocket probe against the TCP listener coturn
already opens on 3478, no in-container tooling required.
2026-07-27 12:00:00 +00:00
Thore Cimbal c0cb864ca2 docs: Add security.html with Passkey/TOTP self-service setup links
Neither Element Web's config.json nor MAS's branding config expose a
generic custom-link mechanism, and /if/user/ is blocked for type=external
Matrix accounts, so the working Authentik MFA setup flows had nowhere to
be discoverable. Added a docs/setup/security.html page, following the
exact existing pattern (ConfigMap -> initContainer cp -> nginx), linked
from index.html's Support section.
2026-07-27 12:00:00 +00:00
Thore Cimbal 235306a840 fix: Set Brand.default_application so "/" doesn't dead-end at /if/user/
Several dead ends (TOTP/WebAuthn setup after an anonymous visit bounces
through login and lands on "/") trace back to Brand.default_application
being unset, which falls back to /if/user/ - blocked for type=external
Matrix accounts. Set it to the matrix Application. Only changes the bare
"/" fallback; explicit URLs like /if/admin/ are unaffected.
2026-07-27 12:00:00 +00:00
Thore Cimbal e9b24a6d1f fix: Append redirect stage to TOTP/WebAuthn self-service setup flows
2FA is intentionally optional (not_configured_action=skip on the login
flow's validate stage). Users who opt in use the built-in single-stage
default-authenticator-totp-setup / -webauthn-setup flows directly, since
/if/user/ is blocked for type=external Matrix accounts. Both flows had
nothing after the setup stage, so completing enrollment fell back to
that same blocked dashboard. Appended the shared redirect stage.
2026-07-27 12:00:00 +00:00
Thore Cimbal 0274f9316c fix: Build matrix-recovery flow and link it as the login page's recovery flow
matrix-recovery existed but had zero stage bindings, and the real login
flow (default-authentication-flow, MAS's authentication_flow) never
linked to it, so users had no working "forgot password" path. Reused
the same default-recovery-* stages the built-in default-recovery-flow
already uses successfully, added our redirect stage at the end, and
set default-authentication-identification.recovery_flow accordingly.
2026-07-27 12:00:00 +00:00
Thore Cimbal 027f567c8b fix: Add redirect stage so matrix-invitation ends up at Element, not authentik dashboard
After the login stage, the flow had no destination, so it fell back to
authentik's own /if/user/ interface - which refuses type=external users
(the correct type for Matrix-only accounts), showing "Die Oberflaeche
kann nur von internen Nutzern geoeffnet werden". Added a static redirect
to https://axion1337.chat as the final stage.
2026-07-27 12:00:00 +00:00
Thore Cimbal d2bcd90291 fix: Clear erroneous validation_policies on matrix-invitation-prompt
The prompt stage had 16 unrelated system policies bound (OOBE, user
settings, recovery, etc.), likely from a "select all" slip while
configuring it manually. They crash when evaluated in an anonymous
enrollment context (AnonymousUser has no group_attributes, etc.),
surfacing as opaque errors after form submit. Cleared live and via
the blueprint so re-application doesn't reintroduce them.
2026-07-27 12:00:00 +00:00
Thore Cimbal 80714fe901 fix: Repair matrix-invitation enrollment flow via Authentik Blueprint
The matrix-invitation flow only had Invite+Prompt stage bindings, both
at order=0 (undefined order), missing the Write/Password/Login stages
entirely — invited users were never written to the DB. Applied the fix
live in-cluster (mirroring the working matrix-enrollment stage chain),
and captured it as an Authentik Blueprint (ConfigMap, mounted via
blueprints.configMaps) so the flow state is reproducible via GitOps
instead of manual admin-UI clicks.
2026-07-27 12:00:00 +00:00
Scrublord MacBadandClaude Haiku 4.5 5bbb03bc52 refactor: Organize troubleshooting docs into docs/troubleshooting/
Move all Authentik troubleshooting guides into dedicated subdirectory:
- DIAGNOSTIK-AUTHENTIK-FLOW.md
- AUTHENTIK-FIX-TEMPLATE.md
- AUTHENTIK-INVITATION-FLOW-FIX.md
- AUTHENTIK-CREATE-INVITATION-FLOW.md

Add README.md with:
- Quick reference guide for each document
- Scenario-based navigation
- Known issues tracking
- Tips and best practices

This keeps the root directory clean and organizes related guides together.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-18 13:03:33 +02:00
Scrublord MacBadandClaude Haiku 4.5 af13688993 docs: Add step-by-step guide to create Authentik Invitation Flow
Root cause analysis:
- Only one matrix-enrollment flow exists
- Used for both standard signup AND invitations
- Causes flow conflicts: 'Found existing plan for other flow'
- Error when Klaus enrollment attempted: 'kein ausstehender benutzer Anfrage wurde verweigert'

Solution:
- Create separate matrix-invitation flow
- Use for invitation links only
- Prevents conflicts and allows proper field capture (email is mandatory)

This guide provides:
1. Step-by-step flow creation (5 stages)
2. Field configuration for Prompt Stage
3. Binding setup for each stage
4. Testing procedure with invitation link
5. Troubleshooting checklist

Related issues: Klaus enrollment failure, Boje enrollment failure

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-18 12:52:35 +02:00
Scrublord MacBadandClaude Haiku 4.5 f70e77127e docs: Add Authentik Invitation Flow repair guide
The Invitation Flow is not properly configured:
- Only username is captured during invitations
- Email field is missing (required for OIDC token generation)
- 'Fehler fehlende Rechte' error indicates incomplete user data

This guide walks through:
1. Diagnosing the current Invitation Flow configuration
2. Creating/repairing the Prompt Stage with email field
3. Adding the Prompt Stage to the Invitation Flow
4. Testing the complete enrollment process

Related to: User Boje enrollment failure via invitation link

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-18 12:36:56 +02:00
Scrublord MacBadandClaude Haiku 4.5 f658ce2980 docs: Add Authentik enrollment flow diagnostics and repair guides
- DIAGNOSTIK-AUTHENTIK-FLOW.md: Comprehensive troubleshooting guide
- AUTHENTIK-FIX-TEMPLATE.md: Repair instructions for common issues

These guides help debug why Boje user was created in Authentik but not synchronized to Matrix.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-18 12:24:49 +02:00
Scrublord MacBad b29c7516d4 Update: Authentik 2026.2.3
Auto-Deploy on Push / verify-and-notify (push) Has been cancelled
2026-05-15 15:05:06 +02:00
Scrublord MacBadandClaude Haiku 4.5 c32f951716 Update TASKS.md: Mark 3 CRITICAL tasks complete
- Authentik Stage 2: OIDC integration verified (login working)
- Hetzner Cloud Firewall: Configured & optimized
- SSH Hardening: Key-only auth, no root, rate limiting verified

Updated status: 9 completed, 0 in-progress, 11+ pending
All 3 CRITICAL security tasks done. Next: Database Backups

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-15 13:48:00 +02:00
Scrublord MacBadandClaude Haiku 4.5 cdfbf7de98 Enable Authentik OIDC integration in MAS
Auto-Deploy on Push / verify-and-notify (push) Has been cancelled
- Add upstream_oauth2_config with Authentik provider credentials
- Configure OIDC with client_id and client_secret from Authentik
- Disable local password authentication (OIDC-only login)
- Set claims mapping: subject, localpart, displayname, email

This enables users to login via Authentik OIDC provider with email
and username claims properly mapped for Matrix user provisioning.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-15 11:05:52 +02:00
Scrublord MacBad b1247b4720 backup old wiki 2026-05-14 23:34:59 +02:00
Scrublord MacBadandClaude Haiku 4.5 6bcbe9cc9e Add Gitea Actions workflows for CI/CD automation
Auto-Deploy on Push / verify-and-notify (push) Has been cancelled
- deploy-on-push.yml: Verify YAML, check SOPS encryption, notify on deployments
- milestone-release.yml: Auto-create releases on milestone tags

Triggers:
- deploy-on-push: On any push to main (apps/clusters changes)
- milestone-release: On git tag m*-*-complete

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 23:29:50 +02:00
Scrublord MacBadandClaude Haiku 4.5 b8da70b14b Add status summary and prioritized next steps to TASKS.md
Add at top of TASKS.md:
- Status Summary table (quick view of progress)
- Priority distribution (CRITICAL/HIGH/MEDIUM/LOW)
- Next Steps section (this week, 1-2 weeks)
- Each task includes: description, effort estimate, blocking factors

Makes task list immediately actionable and progress trackable.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 23:08:00 +02:00
Scrublord MacBadandClaude Haiku 4.5 4cf6702f85 Add comprehensive task list and VSCode todo-tree integration
Add docs/TASKS.md:
- Completed tasks (6): K3S, Flux, ESS, Themes, Desktop Scripts, Monitoring, TURN
- In Progress: Authentik Stage 2 (pending manual config)
- Backlog (15+): Element Call Fork, PostgreSQL migration, NetworkPolicies, etc.
- Security hardening: Host/Cluster/App layer recommendations
- Milestones: Track progress from M1 (Basic) to M7 (Enterprise)

Enhance devcontainer.json:
- Add gruntfuggly.todo-tree extension for task tree view
- Add ms-vscode.makefile-tools for build automation
- Add GitHub.copilot for development assistance
- Configure todo-tree to highlight TASKS.md and deployment guides

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 23:05:33 +02:00
Scrublord MacBadandClaude Haiku 4.5 aee9a34369 Add 5 detailed deployment guides to docs/deployment-guides/
- 01-turn-server-setup.md: TURN Server architecture, deployment, verification
- 02-authentik-identity-provider.md: Two-stage OIDC integration (Stage 1 & Stage 2)
- 03-monitoring-integration.md: Alloy, Prometheus, Loki integration with Selendis
- 04-element-customization.md: Custom themes (7), desktop setup scripts, admin panel
- 05-room-policies.md: Message retention, room publication, auto-join policies

All guides include troubleshooting, configuration examples, and best practices.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 22:47:38 +02:00
Scrublord MacBadandClaude Haiku 4.5 e6ef29c1e9 Add deployment documentation structure & overview
Create docs/deployment-guides/ directory with comprehensive README covering:
- Deployment order for all 5 major features
- Architecture overview
- Critical values & configuration
- Links to detailed guides

Full documentation files:
- 01-turn-server-setup.md
- 02-authentik-identity-provider.md
- 03-monitoring-integration.md
- 04-element-customization.md
- 05-room-policies.md

To be added in follow-up commits.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 22:46:00 +02:00
Scrublord MacBadandClaude Haiku 4.5 857c3965ef Add devcontainer for cross-platform development
- Dockerfile with all required tools (kubectl, flux, helm, sops, age, etc.)
- devcontainer.json with VSCode config and extensions
- postCreateCommand.sh for setup verification
- Comprehensive README with setup instructions for macOS, Windows/WSL2, Linux
- Automatic mounts for kubeconfig, SSH keys, age encryption keys
- SOPS_AGE_KEY_FILE and KUBECONFIG pre-configured

Enables development on Windows, macOS, and Linux with consistent environment.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 22:27:52 +02:00
Scrublord MacBad d37a65f4fa fix element web image version missing 2026-05-14 22:07:34 +02:00
Scrublord MacBad e6abd0fb3f feat: reinstate roomlist fork after config map fix 2026-05-14 21:59:34 +02:00
Scrublord MacBadandClaude Haiku 4.5 d3362180ea fix: Complete JSON structure in element-values.yaml
The config.json was truncated and had unclosed brackets. This prevented
Helm from properly merging the ElementWeb configuration, so the custom themes
were never loaded into the cluster.

This fix:
- Closes the unclosed JSON brackets
- Validates the full JSON structure
- Removes duplicate/extra closing brackets
- Ensures all 6 custom themes are properly included

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 21:55:22 +02:00
Scrublord MacBadandClaude Haiku 4.5 5f50a60071 feat: Automate ConfigMap checksum updates via Git pre-commit hook
- Add pre-commit hook (scripts/hooks/pre-commit) that automatically calculates
  MD5 checksums of ConfigMaps (element-values.yaml, synapse-values.yaml)
- Update annotations in kustomization.yaml to trigger Flux CD HelmRelease syncs
- Add install script (scripts/install-hooks.sh) for easy hook setup
- Add comprehensive documentation (docs/ops-configmap-sync.md) explaining:
  * Why Flux doesn't auto-detect ConfigMap changes
  * How the checksum-based workaround works
  * How to install and use the hook
  * Troubleshooting and manual sync procedures
- Update README.md with post-clone hook installation step

This solves the issue where Flux CD doesn't automatically re-deploy when external
ConfigMaps are modified. Users no longer need manual checksum updates.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 21:43:01 +02:00
Scrublord MacBad 174e9721ed Revert "test: verify pre-commit hook updates checksums"
This reverts commit 5f14376bdb.
2026-05-14 21:42:25 +02:00
Scrublord MacBad 5f14376bdb test: verify pre-commit hook updates checksums 2026-05-14 21:42:13 +02:00
Scrublord MacBadandClaude Haiku 4.5 81a30e21b9 Fix: HelmRelease reconciliation on ConfigMap changes
- Reduce HelmRelease interval from 1h to 5m for faster sync
- Add checksum annotation to trigger reconciliation when element-values.yaml changes
- This ensures Flux CD re-deploys the chart when themes/config updates are made

To update the checksum after editing element-values.yaml:
  md5sum apps/production/custom-configs/element-values.yaml
  # Update the value in apps/production/kustomization.yaml patches[0].patch

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 21:37:07 +02:00
sorb f81fda12d4 Update apps/production/element-server-suite.yaml 2026-05-13 14:48:08 +00:00
sorb fe0165a509 Update apps/production/element-server-suite.yaml
new docker image for elementWeb
2026-05-13 14:43:50 +00:00
Scrublord MacBad dc17158fe2 fix themes 2026-05-11 10:48:16 +02:00
Scrublord MacBad 0c81de057f fix config url element web 2026-05-11 10:43:35 +02:00
sorb aaa197fbb5 Feat: Lab roomslist v3 & new aXion Theme 2026-05-11 08:28:30 +00:00
Scrublord MacBad e0ed10598f Update Element Web to use custom fork image
- Registry: rohana.axion1337.de
- Repository: sorb/threadnet-web
- Tag: v0.1.0
2026-05-10 23:44:35 +02:00
sorb d39ce5a3a0 Feat: deactivate legacy calls 2026-05-10 19:43:54 +00:00
Scrublord MacBad 68bf4e213c Revert to ESS 26.4.0 - URL preview issues in 26.5.0 2026-05-08 00:39:20 +02:00
Scrublord MacBad 644dff9c48 Upgrade to ESS 26.5.0 with oembed support 2026-05-08 00:29:39 +02:00
Scrublord MacBad 8e0f244257 Add oembed configuration directly to synapse values 2026-05-08 00:20:45 +02:00
Scrublord MacBad b000c1b10b Fix oembed configuration format 2026-05-08 00:10:08 +02:00
Scrublord MacBad 24d0cd1201 Add media_repository and oembed configuration for URL previews 2026-05-08 00:06:27 +02:00
Scrublord MacBad 08a38fdfbb Revert to ESS 26.4.0 to test URL preview functionality 2026-05-07 23:58:15 +02:00
Scrublord MacBad eda648eaa7 Add media_repository and thumbnail_generator workers for URL preview support 2026-05-07 23:45:37 +02:00
Scrublord MacBad 575ac0112f fix auto join 2026-05-07 23:23:33 +02:00
Scrublord MacBad 878687f334 test deactivate auto join in synape prior ess upgrade due to invalid room id 2026-05-07 23:11:37 +02:00
Scrublord MacBad 7b4def0fe7 Upgrade ESS 26.5.0 2026-05-07 22:54:05 +02:00
Scrublord MacBad 7b7643cf6e Feat: no new roomlist 2026-05-07 21:26:29 +02:00
Scrublord MacBad a7959474c5 Feat: new room list #32677 2026-05-07 21:16:50 +02:00
Scrublord MacBadandClaude Haiku 4.5 3c2a03e9c3 Deploy Authentik as Identity Provider for Matrix (Stage 1)
Add Authentik v2026.x with embedded PostgreSQL in new namespace. Includes HelmRelease with valuesFrom injection for encrypted credentials, cert-manager Certificate for auth.axion1337.chat, and Traefik IngressRoute. Authentik serves as OIDC provider for MAS; registration via invitation links only.

DNS A-record setup and Authentik UI configuration (OIDC provider creation) are manual post-deployment steps.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-29 23:44:15 +02:00
Scrublord MacBadandClaude Haiku 4.5 8ff438bd24 Implement TURN server (coturn) for WebRTC video calls
Add coturn Deployment with hostNetwork mode and init container for secret substitution. Include SOPS-encrypted shared secret, TLS certificate for turn.axion1337.chat, and Synapse TURN configuration with proper relay URIs and credentials.

Resolves DTLS timeout issues in RTC video calls by providing media relay for clients behind NAT/Firewall.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-29 23:07:52 +02:00
Scrublord MacBadandClaude Haiku 4.5 01ad3f2d71 Fix HelmRepository API version
- Change from v1beta2 to v1 (v1beta2 is deprecated)
- Resolves dry-run failure in monitoring Kustomization

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 16:18:17 +02:00
Scrublord MacBadandClaude Haiku 4.5 34bffe40be Add monitoring stack: kube-state-metrics, node-exporter, and Alloy
- New monitoring namespace
- HelmRepositories: prometheus-community, grafana
- kube-state-metrics: Kubernetes state metrics
- prometheus-node-exporter: Host metrics DaemonSet
- Alloy: Multi-target scraper pushing to Selendis (10.0.0.3:9090 Prometheus, 10.0.0.3:3100 Loki)
  - Scrapes Flux controllers (flux-system:8080)
  - Scrapes kube-state-metrics (monitoring:8080)
  - Scrapes node-exporter DaemonSet (monitoring:9100)
  - Scrapes Synapse metrics (matrix:9000)
  - Pushes pod logs to Loki
- Monitoring Kustomization added to flux-system

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 16:14:17 +02:00
Scrublord MacBadandClaude Haiku 4.5 1a1c8ee2c6 Simplify room_list_publication_rules structure
- Remove unnecessary fields, use minimal rule set
- Allow all users to publish rooms

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 15:31:30 +02:00
Scrublord MacBadandClaude Haiku 4.5 78454ba2ef Add room_list_publication_rules to allow room publishing
- Allow all users to publish public rooms in room list
- Fixes 403 'Not allowed to publish room' error
- Applies to rooms with join_rule: public

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 15:11:32 +02:00
Scrublord MacBadandClaude Haiku 4.5 c98997f75e Fix auto_join_rooms configuration property name
- Rename property from auto-join-rooms to auto_join (underscore instead of dash)
- Keep YAML structure with embedded config

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 14:40:45 +02:00
Scrublord MacBadandClaude Haiku 4.5 a323c6bb65 Add auto-join rooms configuration for onboarding
- New users automatically join !NkFTshKWJMgdTqTbTh:axion1337.chat (onboarding room)
- Federated guests do not auto-join (auto_join_rooms_for_guests: false)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 14:15:45 +02:00
Scrublord MacBadandClaude Haiku 4.5 47625c05b6 Rename retention-config to retention in synapse additional config
- Use simpler property name 'retention' instead of 'retention-config'
- Helm Chart schema may not allow 'retention-config' naming convention

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 13:53:25 +02:00
Scrublord MacBadandClaude Haiku 4.5 42483f32b5 Fix synapse retention-config placement in values
- Move retention-config from top-level synapse to additional section
- Helm Chart schema requires custom configs under synapse.additional
- Clean up comments (keep inline comments minimal)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-24 13:48:57 +02:00
sorb ee6cdb7982 feat retention-config 2026-04-24 09:27:42 +00:00
Scrublord MacBadandClaude Haiku 4.5 4a95350c72 Add beautiful HTML landing page to element-web-docs setup
- Create index.html with styled setup guide and download links
- Update init-container to copy index.html to nginx
- Configure nginx to serve index.html as default for /docs/setup/
- Remove directory listing (autoindex), show proper HTML instead

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 18:52:33 +02:00
Scrublord MacBadandClaude Haiku 4.5 c85dd39eba Fix Element Web docs server routing and remove redundant nginx Ingress
- Remove duplicate nginx Ingress from element-web-docs-server.yaml (using Traefik instead)
- Traefik IngressRoute in apex-ingress.yaml now handles /docs/setup routing
- Deployment and Service remain unchanged for file serving

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 18:42:57 +02:00
Scrublord MacBad bdba4f3e34 fix: use init-container instead of postStart hook 2026-04-23 18:37:08 +02:00
Scrublord MacBad 80fa13b63f feat: separate nginx deployment to serve Element Desktop setup scripts 2026-04-23 18:36:13 +02:00
Scrublord MacBad cf8bc00613 fix: use init-container to copy docs from ConfigMap to nginx html 2026-04-23 18:34:45 +02:00
Scrublord MacBad 0c67a249fa fix: add element-web-docs-configmap to kustomization resources 2026-04-23 18:25:18 +02:00
Scrublord MacBad 4bc0e10631 fix: correct ConfigMap keys and mount structure for /docs/setup/ 2026-04-23 18:18:15 +02:00
Scrublord MacBad 3e07d6c613 fix: move element-web-docs ConfigMap to correct location 2026-04-23 18:17:04 +02:00
Scrublord MacBad 336f2aa1cb feat: serve Element Desktop setup scripts via Element Web at /docs/setup/ 2026-04-23 18:16:03 +02:00
Scrublord MacBad 659acdf601 docs: add Element Desktop setup scripts for Windows, macOS, and Linux 2026-04-23 16:42:16 +02:00
Scrublord MacBad 147ac079f7 feat: add configUrl for Element Desktop auto-loading 2026-04-23 16:33:06 +02:00
Scrublord MacBad 0efe91b1bd custom themes 2026-04-23 15:39:33 +02:00
Scrublord MacBad 6aff1f86d6 fix final mas fix 2026-04-23 15:25:44 +02:00
Scrublord MacBad bdca6d8840 fix mas 2026-04-23 14:01:48 +02:00
Scrublord MacBad b6e2c6f00b missing param 2026-04-23 13:53:24 +02:00
Scrublord MacBad 37149b195b fix mas? 2026-04-23 13:50:35 +02:00
Scrublord MacBad 13e597769c fix secret handling 2026-04-23 11:28:14 +02:00
Scrublord MacBad c17e8f528f fix mas? 2026-04-23 11:25:00 +02:00
Scrublord MacBad 9fdac55e25 fix custom themes syntax 2026-04-23 11:04:47 +02:00
Scrublord MacBad eb21dc9494 feat custom themes 2026-04-23 10:49:58 +02:00
125 changed files with 24602 additions and 55 deletions
Vendored
BIN
View File
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
FROM debian:bookworm-slim
# Install base tools
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
wget \
git \
ca-certificates \
gnupg \
lsb-release \
apt-transport-https \
vim \
nano \
jq \
yq \
zsh \
sudo \
openssh-client \
gosu \
&& rm -rf /var/lib/apt/lists/*
# 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/*
# Install Helm
RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Install Flux CLI
RUN curl -s https://fluxcd.io/install.sh | bash
# 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
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=$(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
# 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 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
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/docker-init.sh"]
CMD ["/bin/zsh"]
+241
View File
@@ -0,0 +1,241 @@
# 🐳 DevContainer für ESS Community GitOps
Dieses DevContainer-Setup ermöglicht dir, auf **macOS, Windows und Linux** einheitlich zu entwickeln.
## 🚀 Schnelstart
### VSCode mit Remote Containers Extension
1. **VSCode Extension installieren:**
- Öffne VSCode → Extensions → Suche nach `Dev Containers` (Microsoft)
- Installiere sie
2. **GitOps Verzeichnis öffnen:**
```bash
cd "april mit Ansible/prod/gitops"
code .
```
3. **DevContainer starten:**
- Klick auf `><` Symbol unten links in VSCode
- Wähle `Reopen in Container`
- Warte, bis das Image gebaut wurde (~3-5 Min beim ersten Mal)
### Alternative: Docker + CLI
```bash
docker build -t ess-gitops .devcontainer
docker run -it --rm \
-v ~/.kube:/home/vscode/.kube \
-v ~/.ssh:/home/vscode/.ssh \
-v ~/.age:/home/vscode/.age \
-v /var/run/docker.sock:/var/run/docker.sock \
ess-gitops
```
## 📦 Enthaltene Tools
- **kubectl** - Kubernetes CLI
- **flux** - FluxCD GitOps Controller CLI
- **helm** - Kubernetes Package Manager
- **sops** - Secret Operations (Verschlüsselung)
- **age** - Modern File Encryption
- **docker** - Container CLI (über Host-Socket)
- **git** - Versionskontrolle
- **jq/yq** - JSON/YAML Processing
- **zsh + oh-my-zsh** - Shell mit Plugins
## 🔐 Wichtige Verzeichnis-Binds
Der Container mountet automatisch:
| Host | Container | Zweck |
|------|-----------|-------|
| `~/.kube` | `/home/vscode/.kube` | Kubernetes Config |
| `~/.ssh` | `/home/vscode/.ssh` | SSH Keys |
| `~/.age` | `/home/vscode/.age` | Age Encryption Keys |
| `/var/run/docker.sock` | `/var/run/docker.sock` | Docker Daemon (für `docker` Befehle) |
## ⚙️ Kubeconfig Einrichten
1. **Host-Machine (z.B. macOS):**
```bash
# Stelle sicher, dass ~/.kube/config existiert und den richtigen Cluster enthält
kubectl get nodes
```
2. **Im Container:**
```bash
kubectl get nodes # Sollte jetzt auch dein Cluster zeigen
kubectl config current-context
```
## 🔐 SOPS + Age Setup
Damit du Secrets bearbeiten kannst, brauchst du den privaten `age`-Key. Dieser ist in `.sops.yaml` konfiguriert.
### Schritt 1: Age-Key bereitstellen
```bash
# Host-Machine: Key-Datei erstellen
mkdir -p ~/.age
# Füge deinen privaten Key ein (Format: "age-secret-key-...")
echo "age-secret-key-xxx..." > ~/.age/keys.txt
chmod 600 ~/.age/keys.txt
```
### Schritt 2: Im Container konfigurieren
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-secret.yaml
```
### Schritt 3: VSCode Integration (optional)
Um die Umgebungsvariable beim Start zu setzen, nutze die `.devcontainer/devcontainer.json`:
```json
"remoteEnv": {
"KUBECONFIG": "/home/vscode/.kube/config",
"SOPS_AGE_KEY_FILE": "/home/vscode/.age/keys.txt"
}
```
### Wie es funktioniert
- `.sops.yaml` definiert, dass Secrets mit `age` verschlüsselt werden
- Beim Öffnen mit `sops <datei>` wird die Datei entschlüsselt → du editierst den plaintext in deinem Editor
- Beim Speichern wird alles wieder automatisch verschlüsselt
- **Wichtig:** Niemals den plaintext-Buffer commiten!
## 📝 Nach Container-Start: Git Hooks Installieren
Wichtig für die ConfigMap Auto-Sync (verhindert Merge-Konflikte):
```bash
./scripts/install-hooks.sh
```
Mehr Details: `docs/ops-configmap-sync.md`
## 📝 Nützliche Befehle
```bash
# Status des Deployments
kubectl get pods -n matrix
flux get helmreleases -A
# Secrets bearbeiten (mit verschlüsselung)
sops apps/production/custom-configs/mas-secret.yaml
# FluxCD Sync erzwingen
flux reconcile kustomization production-apps --with-source
# Zertifikate debuggen
kubectl get certificate -n matrix
kubectl describe certificate matrix-ingress -n matrix
# HelmRelease Status prüfen
flux describe helmrelease matrix-stack -n matrix
```
## 🛠️ Anpassungen für Windows/WSL2
Falls du Windows nutzt:
1. **Docker Desktop installieren** (mit WSL2 Backend)
2. **VSCode mit WSL Extension öffnen**
3. **Im WSL Terminal:**
```bash
cd /mnt/c/path/to/projekt
code .
```
4. Dann `Dev Containers: Reopen in Container`
Das funktioniert seamless, weil Docker Desktop unter WSL2 läuft.
## 🔧 Troubleshooting
### Problem: `SOPS_AGE_KEY_FILE not found`
**Lösung:** Key muss in `~/.age/keys.txt` auf der Host-Machine sein:
```bash
# Host
mkdir -p ~/.age
echo "your-age-private-key" > ~/.age/keys.txt
```
Der Container mountet `~/.age` automatisch → sollte dann funktionieren.
### Problem: `kubectl: connection refused`
**Lösung:** `~/.kube/config` muss auf Host vorhanden sein:
```bash
# Host
kubectl get nodes # Test, ob Zugriff existiert
# Dann Container neustarten
```
### Problem: `HelmChart is not ready: stat ... no such file or directory`
Siehe `README.md` → **Issue 1**. Kontrolliere:
- `HelmRepository` nutzt `type: oci`
- URL ist `oci://ghcr.io/element-hq/ess-helm`
### Problem: `values don't meet the specifications of the schema`
Siehe `README.md` → **Issue 2**. Häufige Fehler:
- `tls:` darf nicht im `ingress:` Block sein
- `serverName` muss auf Root-Ebene der `values` stehen
- Komponenten-Namen: `camelCase` (z.B. `elementWeb`, `matrixAuthenticationService`)
### Problem: Let's Encrypt `403 Order's status is processing`
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)
- [FluxCD Dokumentation](https://fluxcd.io)
- [SOPS Anleitung](https://github.com/getsops/sops)
- **Projekt-README:** `README.md` (Architektur, Issues, Best Practices)
- **Setup-Docs:** `docs/setup/`
- **Install-Guide:** `docs/install.md`
+72
View File
@@ -0,0 +1,72 @@
{
"name": "ESS Community GitOps",
"build": {
"dockerfile": "Dockerfile",
"context": "."
},
"mounts": [
"source=${localEnv:HOME}/.kube,target=/home/vscode/.kube,type=bind,consistency=cached",
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached",
"source=${localEnv:HOME}/.age,target=/home/vscode/.age,type=bind,consistency=cached",
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
],
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"remoteEnv": {
"KUBECONFIG": "/home/vscode/.kube/config",
"SOPS_AGE_KEY_FILE": "/home/vscode/.age/keys.txt"
},
"customizations": {
"vscode": {
"extensions": [
"ms-kubernetes-tools.vscode-kubernetes-tools",
"redhat.vscode-yaml",
"redhat.vscode-commons",
"monokai.theme-monokai-pro-vscode",
"eamodio.gitlens",
"gruntfuggly.todo-tree",
"ms-vscode.makefile-tools",
"GitHub.copilot"
],
"settings": {
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"yaml.schemas": {
"https://json.schemastore.org/kustomization.json": "**/kustomization.yaml",
"https://json.schemastore.org/helmrelease.json": "**/*helmrelease*.yaml"
},
"editor.theme": "Monokai Pro",
"todo-tree.general.showActivityBarBadge": true,
"todo-tree.general.tags": [
"TODO",
"FIXME",
"BUG",
"HACK",
"NOTE",
"XXX",
"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",
"**/docs/deployment-guides/*.md"
]
}
}
},
"postCreateCommand": "bash .devcontainer/postCreateCommand.sh",
"forwardPorts": []
}
+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 "$@"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -e
echo "🚀 Setting up ESS Community GitOps devcontainer..."
# Verify all required tools are installed
echo "✅ Verifying installed tools..."
commands=("kubectl" "flux" "helm" "sops" "age" "git" "docker")
for cmd in "${commands[@]}"; do
if command -v $cmd &> /dev/null; then
version=$($cmd version 2>/dev/null | head -1 || echo "installed")
echo "$cmd: $version"
else
echo "$cmd: NOT FOUND"
exit 1
fi
done
# Create necessary directories
echo "📁 Creating home directories..."
mkdir -p ~/.kube ~/.ssh ~/.age
# Print useful information
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-secret.yaml (edit secrets)"
echo ""
echo "🔗 For kubeconfig setup:"
echo " - Copy your ~/.kube/config to access the cluster"
echo " - Run: kubectl get nodes"
echo ""
echo "✨ Devcontainer setup complete!"
+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.
+81 -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
@@ -53,6 +58,12 @@ winget install Kubernetes.kubectl FluxCD.Flux Mozilla.sops age-encryption.age He
1. **Kubeconfig:** Stelle sicher, dass die Datei `~/.kube/config` mit den Zugangsdaten zu deinem K3s-Cluster gefüllt ist. Test: `kubectl get nodes`.
2. **SOPS Key:** Du benötigst den privaten `age`-Key (oder GPG-Key), der in der `.sops.yaml` des Repositories hinterlegt ist, um Secrets bearbeiten zu können.
3. **Git Hooks installieren:** Nach dem Klonen dieses Repositories müssen Git Hooks installiert werden, um ConfigMap-Änderungen automatisch zu tracken:
```bash
cd prod/gitops
./scripts/install-hooks.sh
```
Siehe [📖 GitOps ConfigMap Auto-Sync](docs/ops-configmap-sync.md) für Details.
-----
@@ -64,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)
@@ -74,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
@@ -100,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).
-----
@@ -150,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
@@ -210,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
```
-----
@@ -238,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: {}
+509
View File
@@ -0,0 +1,509 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: authentik-blueprints
namespace: authentik
data:
matrix-invitation-flow.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-invitation-flow
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# Reaffirm the flow itself (already created manually; matched by slug)
- model: authentik_flows.flow
state: present
identifiers:
slug: matrix-invitation
id: matrix_invitation_flow
attrs:
name: matrix-invitation
title: matrix-invitation
designation: enrollment
# The prompt stage had accumulated 16 unrelated system validation_policies
# (e.g. default-user-settings-authorization, default-oobe-password-usable)
# 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:
- !KeyOf username_unique_ci
# Correct stage chain, mirroring the working matrix-enrollment flow:
# Invite -> Prompt (username/email/password) -> Write -> Password -> Login
# Root cause of the original bug: only Invite+Prompt were bound, both at
# order=0, so the flow never wrote the user to the DB or logged them in.
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 0
attrs:
stage: !Find [authentik_stages_invitation.invitationstage, [name, matrix-enrollment-invitation]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 1
attrs:
stage: !Find [authentik_stages_prompt.promptstage, [name, matrix-invitation-prompt]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 2
attrs:
stage: !Find [authentik_stages_user_write.userwritestage, [name, default-source-enrollment-write]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 3
attrs:
stage: !Find [authentik_stages_password.passwordstage, [name, default-authentication-password]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 4
attrs:
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-source-enrollment-login]]
# Without an explicit destination, the flow falls back to Authentik's own
# /if/user/ dashboard, which refuses type=external users ("Die Oberflaeche
# kann nur von internen Nutzern geoeffnet werden") - exactly the user type
# these Matrix-only accounts correctly have. Send them to Element instead.
- model: authentik_stages_redirect.redirectstage
state: present
identifiers:
name: matrix-invitation-redirect
id: matrix_invitation_redirect_stage
attrs:
mode: static
target_static: https://axion1337.chat
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_invitation_flow
order: 5
attrs:
stage: !KeyOf matrix_invitation_redirect_stage
matrix-recovery-flow.yaml: |
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: matrix-recovery-flow
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# matrix-recovery existed but had zero stage bindings (dead flow), and the
# real login flow (default-authentication-flow, used by the MAS OAuth2
# provider's authentication_flow) didn't link to it at all - no "Forgot
# password?" link was ever shown. Reuses the same default-recovery-*
# 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
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: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 10
attrs:
stage: !Find [authentik_stages_identification.identificationstage, [name, default-recovery-identification]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 20
attrs:
stage: !Find [authentik_stages_email.emailstage, [name, default-recovery-email]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 30
attrs:
stage: !Find [authentik_stages_prompt.promptstage, [name, "Change your password"]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 40
attrs:
stage: !Find [authentik_stages_user_write.userwritestage, [name, default-recovery-user-write]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, matrix-recovery]]
order: 100
attrs:
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-recovery-user-login]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
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.
#
# ⚠️ 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: !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
metadata:
name: matrix-mfa-setup-redirect
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# 2FA is optional (default-authentication-mfa-validation has
# not_configured_action=skip - login never blocks on missing MFA).
# Users who want to opt in use these built-in single-stage setup flows
# directly (unreachable via /if/user/, which is blocked for type=external
# Matrix accounts). Without a stage after the setup itself, completion
# fell back to the same blocked /if/user/ dashboard - append our redirect.
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, default-authenticator-totp-setup]]
order: 10
attrs:
stage: !Find [authentik_stages_redirect.redirectstage, [name, matrix-invitation-redirect]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, default-authenticator-webauthn-setup]]
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
metadata:
name: matrix-brand-default-app
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
# Root cause behind several dead ends: an authenticated user hitting "/"
# with no other destination (e.g. after logging in mid-way through the
# TOTP/WebAuthn setup flows) falls back to Brand.default_application: if
# unset, that's /if/user/, which type=external Matrix accounts can't
# open. Only affects the bare "/" fallback - explicit URLs like
# /if/admin/ are unaffected, so internal/staff access is unchanged.
- model: authentik_brands.brand
state: present
identifiers:
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
+25
View File
@@ -0,0 +1,25 @@
apiVersion: v1
kind: Secret
metadata:
name: authentik-credentials
namespace: authentik
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:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRekJuZythYzliTFJ3RlhS
R2p6TG9NeFdabFlPRWtpNHJMYVVxTWZEcmlRClk0WUorSzdxNlcyWHYwWFBTMnlq
TlM4dENSSit2S3VGSzJCeTRTYU52dmcKLS0tIEF0WkV0M25OSEo1N0FEYXI5Q0Z6
QXVrY1NTeHZkeTlPRWNlVThzWno3T0kKC0KBoLT64GNqb8Ri9u69G7nqb1KftwwP
/24aVHrPxKi9d4ij9n3bvCYDF4rhtfexhrE4n7CfuKn2DcSiuTniuw==
-----END AGE ENCRYPTED FILE-----
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
+105
View File
@@ -0,0 +1,105 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: authentik
namespace: authentik
spec:
interval: 1h
chart:
spec:
chart: authentik
version: "2026.2.3"
sourceRef:
kind: HelmRepository
name: goauthentik
namespace: flux-system
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
valuesFrom:
- kind: Secret
name: authentik-credentials
valuesKey: secret_key
targetPath: authentik.secret_key
- kind: Secret
name: authentik-credentials
valuesKey: pg-password
targetPath: authentik.postgresql.password
- kind: Secret
name: authentik-credentials
valuesKey: pg-password
targetPath: postgresql.auth.password
- kind: Secret
name: authentik-credentials
valuesKey: smtp-password
targetPath: authentik.email.password
values:
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
error_reporting:
enabled: false
email:
host: smtp.ionos.de
port: 587
username: gamemaster@axion1337.chat
use_tls: true
from: "Authentik <gamemaster@axion1337.chat>"
blueprints:
configMaps:
- authentik-blueprints
server:
ingress:
enabled: false
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 1Gi
worker:
resources:
requests:
cpu: 50m
memory: 512Mi
limits:
memory: 1Gi
postgresql:
enabled: true
auth:
username: authentik
database: authentik
primary:
persistence:
enabled: true
size: 8Gi
resources:
requests:
cpu: 50m
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
+12
View File
@@ -0,0 +1,12 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: auth-axion1337-chat-cert
namespace: authentik
spec:
secretName: auth-axion1337-chat-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- auth.axion1337.chat
+8
View File
@@ -0,0 +1,8 @@
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: goauthentik
namespace: flux-system
spec:
interval: 1h
url: https://charts.goauthentik.io
+16
View File
@@ -0,0 +1,16 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: authentik
namespace: authentik
spec:
entryPoints:
- websecure
tls:
secretName: auth-axion1337-chat-tls
routes:
- match: Host(`auth.axion1337.chat`)
kind: Rule
services:
- name: authentik-server
port: 80
+14
View File
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- helm-repo.yaml
- authentik-secret.yaml
- authentik-blueprints.yaml
- certificate.yaml
- authentik.yaml
- ingress.yaml
- networkpolicy.yaml
# Backup zur Hetzner Storage Box (Issues #6 + #15)
- authentik-backup-secret.yaml
- authentik-backup.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: authentik
+149
View File
@@ -0,0 +1,149 @@
# Default-deny ingress for the authentik namespace, with explicit allow rules for the
# traffic paths that actually need to reach in: Traefik (kube-system) for the public
# auth.axion1337.chat endpoint and ACME HTTP-01 challenges, and MAS (matrix namespace)
# for upstream OIDC calls. Egress is intentionally untouched (federation-equivalent
# outbound calls like SMTP aren't restricted here).
#
# 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:
name: default-deny-ingress
namespace: authentik
spec:
podSelector: {}
policyTypes:
- Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-authentik-server
namespace: authentik
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: server
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: matrix
ports:
# NetworkPolicy matches the pod's actual container port, not the Service's
# external port - the authentik-server Service maps 80->9000, 443->9443.
- protocol: TCP
port: 9000
- protocol: TCP
port: 9443
---
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
spec:
podSelector:
matchLabels:
acme.cert-manager.io/http01-solver: "true"
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
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.
+130
View File
@@ -0,0 +1,130 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: alloy-config
namespace: monitoring
data:
config.alloy: |
// Kubernetes pod discovery
discovery.kubernetes "k8s_pods" {
role = "pod"
}
// Relabel for Prometheus scrape
discovery.relabel "prometheus_pods" {
targets = discovery.kubernetes.k8s_pods.targets
rule {
source_labels = ["__meta_kubernetes_pod_annotation_prometheus_io_scrape"]
action = "keep"
regex = "true"
}
rule {
source_labels = ["__meta_kubernetes_pod_annotation_prometheus_io_path"]
action = "replace"
target_label = "__metrics_path__"
regex = "(.+)"
}
rule {
source_labels = ["__address__", "__meta_kubernetes_pod_annotation_prometheus_io_port"]
action = "replace"
regex = "([^:]+)(?::\\d+)?;(\\d+)"
replacement = "$1:$2"
target_label = "__address__"
}
rule {
source_labels = ["__meta_kubernetes_namespace"]
action = "replace"
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
action = "replace"
target_label = "pod"
}
}
// Scrape Flux controllers (flux-system namespace, port 8080)
discovery.kubernetes "flux_pods" {
role = "pod"
namespaces {
names = ["flux-system"]
}
}
discovery.relabel "flux_scrape" {
targets = discovery.kubernetes.flux_pods.targets
rule {
source_labels = ["__meta_kubernetes_pod_container_port_number"]
action = "keep"
regex = "8080"
}
rule {
source_labels = ["__meta_kubernetes_namespace"]
action = "replace"
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
action = "replace"
target_label = "pod"
}
}
// Scrape kube-state-metrics
prometheus.scrape "kube_state_metrics" {
targets = [{
__address__ = "kube-state-metrics.monitoring.svc.cluster.local:8080",
}]
forward_to = [prometheus.remote_write.selendis.receiver]
scrape_interval = "30s"
scrape_timeout = "10s"
}
// Scrape Flux controllers
prometheus.scrape "flux" {
targets = discovery.relabel.flux_scrape.output
forward_to = [prometheus.remote_write.selendis.receiver]
scrape_interval = "30s"
scrape_timeout = "10s"
job_name = "flux"
}
// 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:9001",
}]
forward_to = [prometheus.remote_write.selendis.receiver]
scrape_interval = "30s"
scrape_timeout = "10s"
}
// Kubernetes pod logs to Loki
loki.source.kubernetes "k8s_logs" {
targets = discovery.kubernetes.k8s_pods.targets
forward_to = [loki.write.selendis.receiver]
}
// Remote write to Selendis Prometheus
prometheus.remote_write "selendis" {
endpoint {
url = "http://10.0.0.3:9090/api/v1/write"
write_relabel_config {
source_labels = ["__name__"]
regex = "go_.*|process_.*"
action = "drop"
}
}
}
// Remote write logs to Selendis Loki
loki.write "selendis" {
endpoint {
url = "http://10.0.0.3:3100/loki/api/v1/push"
}
}
+33
View File
@@ -0,0 +1,33 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: alloy
namespace: monitoring
spec:
interval: 1h
chart:
spec:
chart: alloy
version: "0.x"
sourceRef:
kind: HelmRepository
name: grafana
namespace: flux-system
values:
alloy:
configMap:
name: alloy-config
replicaCount: 1
serviceAccount:
create: true
name: alloy
rbac:
create: true
podAnnotations:
prometheus.io/scrape: "false"
resources:
limits:
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
+18
View File
@@ -0,0 +1,18 @@
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: prometheus-community
namespace: flux-system
spec:
interval: 1h
url: https://prometheus-community.github.io/helm-charts
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: grafana
namespace: flux-system
spec:
interval: 1h
url: https://grafana.github.io/helm-charts
+22
View File
@@ -0,0 +1,22 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: kube-state-metrics
namespace: monitoring
spec:
interval: 1h
chart:
spec:
chart: kube-state-metrics
version: "5.x"
sourceRef:
kind: HelmRepository
name: prometheus-community
namespace: flux-system
values:
replicas: 1
service:
port: 8080
prometheus:
monitor:
enabled: false
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- helm-repos.yaml
- kube-state-metrics.yaml
- alloy-config.yaml
- alloy.yaml
- networkpolicy.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: monitoring
+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
+15
View File
@@ -28,6 +28,21 @@ spec:
services:
- name: matrix-stack-well-known
port: 8010
# Element Desktop Setup Skripte
- match: Host(`axion1337.chat`) && PathPrefix(`/docs/setup`)
kind: Rule
priority: 50
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: {}
+31
View File
@@ -0,0 +1,31 @@
apiVersion: v1
kind: Secret
metadata:
name: coturn-secret
namespace: matrix
stringData:
TURN_SECRET: ENC[AES256_GCM,data:Cbu5SoxQp0L9WFZFbEiyK8j0IJuSgoJE9OpRKZVXyF+PcTM+ewD+3TI8xq+g0C34XDWeaJdxaj3B4yyKsqQLIA==,iv:lW8lD3D75Z0b0EfBj5myUrv4GWMvRTBELjhCVm+QJjc=,tag:nC5L/PcYPoLJP1whSSfSTA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQMncxOEhRZC9jcHpjT3dW
Q1RNaW5pc00zVnJhWHRnZHd3TGhURWNZU2dJCk85bXJkbTEyd1ZybjhDT05pL2c3
ZU9EUSt1eDlSQWJyVGtsak1oS3FSR2sKLS0tIEFUdXVHL0V1ZW5VMVVBZEJaYUIw
U1BrYlJyQVZkZFhBdmdwbDMyK3lTQkkKEaSy1o+IICf2uaT6olapRJa/duXxjOBg
OqRS9axnJ71XxEnHjLTsCbkI5b+8Fux08qKaH9sMsJrWOiSHDdTXXQ==
-----END AGE ENCRYPTED FILE-----
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
+181
View File
@@ -0,0 +1,181 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: coturn-config
namespace: matrix
data:
turnserver.conf: |
# TURN Server configuration
realm=axion1337.chat
# Listen ports
listening-port=3478
listening-ip=0.0.0.0
alt-listening-port=5349
alt-listening-ip=0.0.0.0
# External IPs (for clients behind NAT)
relay-ip=49.13.132.245
external-ip=49.13.132.245
# Relay port range
min-bps=0
bps-capacity=0
# Authentication
use-auth-secret
static-auth-secret=$TURN_SECRET
# HTTPS/TLS
cert=/etc/coturn/tls/tls.crt
pkey=/etc/coturn/tls/tls.key
# Performance tuning
max-bps=0
bps-capacity=0
log-file=stdout
# Logging
verbose
---
apiVersion: v1
kind: Service
metadata:
name: coturn
namespace: matrix
spec:
type: ClusterIP
ports:
- name: stun-udp
port: 3478
protocol: UDP
- name: stun-tcp
port: 3478
protocol: TCP
- name: turns-tcp
port: 5349
protocol: TCP
selector:
app: coturn
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: coturn
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
template:
metadata:
labels:
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
# 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
- |
TURN_SECRET=$(cat /etc/coturn-secret/TURN_SECRET)
sed "s|\$TURN_SECRET|$TURN_SECRET|g" /etc/coturn-template/turnserver.conf > /etc/coturn/turnserver.conf
chmod 644 /etc/coturn/turnserver.conf
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 50m
memory: 32Mi
volumeMounts:
- name: config-template
mountPath: /etc/coturn-template
- name: config
mountPath: /etc/coturn
- name: secret
mountPath: /etc/coturn-secret
readOnly: true
containers:
- name: coturn
# 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
containerPort: 3478
protocol: UDP
- name: stun-tcp
containerPort: 3478
protocol: TCP
- name: turns-tcp
containerPort: 5349
protocol: TCP
volumeMounts:
- name: config
mountPath: /etc/coturn
- name: tls
mountPath: /etc/coturn/tls
readOnly: true
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
livenessProbe:
tcpSocket:
port: 3478
initialDelaySeconds: 30
periodSeconds: 10
volumes:
- name: config
emptyDir: {}
- name: config-template
configMap:
name: coturn-config
- name: secret
secret:
secretName: coturn-secret
defaultMode: 0400
- name: tls
secret:
secretName: turn-axion1337-chat-tls
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- matrix
@@ -0,0 +1,186 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ess-element-custom
namespace: matrix
data:
values.yaml: |
elementWeb:
additional:
config.json: |
{
"brand": "aXion1337.Chat",
"default_theme": "aXion1337 Dark",
"show_labs_settings": true,
"features": {
"feature_qr_code_login": true,
"feature_new_room_list": true
},
"element_call": {
"use_exclusively": true
},
"setting_defaults": {
"custom_themes": [
{
"name": "aXion1337 Dark true",
"is_dark": true,
"colors": {
"accent-color": "#ffaf0f",
"primary-color": "#ffaf0f",
"secondary-color": "#ffaf0f"
}
},
{
"name": "Deep Purple",
"is_dark": true,
"colors": {
"accent-color": "#6503b3",
"primary-color": "#368bd6",
"warning-color": "#b30356",
"sidebar-color": "#15171B",
"roomlist-background-color": "#22262E",
"roomlist-text-color": "#A1B2D1",
"roomlist-text-secondary-color": "#EDF3FF",
"roomlist-highlights-color": "#343A46",
"roomlist-separator-color": "#a1b2d1",
"timeline-background-color": "#181b21",
"timeline-text-color": "#EDF3FF",
"timeline-text-secondary-color": "#A1B2D1",
"timeline-highlights-color": "#22262E"
}
},
{
"name": "Discord Dark",
"is_dark": true,
"colors": {
"accent-color": "#747ff4",
"accent": "#747ff4",
"primary-color": "#00aff4",
"warning-color": "#faa81ad9",
"alert": "#faa81ad9",
"sidebar-color": "#202225",
"roomlist-background-color": "#2f3136",
"roomlist-text-color": "#dcddde",
"roomlist-text-secondary-color": "#8e9297",
"roomlist-highlights-color": "#4f545c52",
"roomlist-separator-color": "#40444b",
"timeline-background-color": "#36393f",
"timeline-text-color": "#dcddde",
"secondary-content": "#dcddde",
"tertiary-content": "#dcddde",
"timeline-text-secondary-color": "#b9bbbe",
"timeline-highlights-color": "#04040512",
"reaction-row-button-selected-bg-color": "#4752c4",
"menu-selected-color": "#4752c4",
"focus-bg-color": "#4752c4",
"room-highlight-color": "#4752c4",
"other-user-pill-bg-color": "#4752c4",
"togglesw-off-color": "#72767d"
},
"compound": {
"--cpd-color-theme-bg": "#0019ff",
"--cpd-color-bg-canvas-default": "#2f3136",
"--cpd-color-bg-subtle-secondary": "#2f3136",
"--cpd-color-bg-subtle-primary": "#4f545c52",
"--cpd-color-bg-action-primary-rest": "#dcddde",
"--cpd-color-bg-action-secondary-rest": "#2f3136",
"--cpd-color-bg-critical-primary": "#fd3f3c",
"--cpd-color-bg-critical-subtle": "#745862",
"--cpd-color-bg-critical-hovered": "#fd3f3c",
"--cpd-color-bg-accent-rest": "#4cb387",
"--cpd-color-text-primary": "#dcddde",
"--cpd-color-text-secondary": "#b9bbbe",
"--cpd-color-text-action-accent": "#b9bbbe",
"--cpd-color-text-critical-primary": "#fd3f3c",
"--cpd-color-text-success-primary": "#4cb387",
"--cpd-color-icon-primary": "#dcddde",
"--cpd-color-icon-secondary": "#dcddde",
"--cpd-color-icon-tertiary": "#a7a0a7",
"--cpd-color-icon-accent-tertiary": "#4cb387",
"--cpd-color-border-interactive-primary": "#5d6064",
"--cpd-color-border-interactive-secondary": "#5d6064",
"--cpd-color-border-critical-primary": "#fd3f3c",
"--cpd-color-border-success-subtle": "#4cb387"
}
},
{
"name": "Electric Blue",
"is_dark": false,
"colors": {
"accent-color": "#3596fc",
"primary-color": "#368bd6",
"warning-color": "#ff4b55",
"sidebar-color": "#27303a",
"roomlist-background-color": "#f3f8fd",
"roomlist-text-color": "#2e2f32",
"roomlist-text-secondary-color": "#61708b",
"roomlist-highlights-color": "#ffffff",
"roomlist-separator-color": "#e3e8f0",
"timeline-background-color": "#ffffff",
"timeline-text-color": "#2e2f32",
"timeline-text-secondary-color": "#61708b",
"timeline-highlights-color": "#f3f8fd",
"username-colors": ["#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#9400d3", "#ff1493"],
"avatar-background-colors": ["#cc0000", "#cc6600", "#cccc00", "#00cc00", "#0000cc", "#3b0066", "#7a00b3", "#cc1077"]
},
"compound": {
"--cpd-color-icon-accent-tertiary": "var(--cpd-color-blue-800)",
"--cpd-color-text-action-accent": "var(--cpd-color-blue-900)"
}
},
{
"name": "Everforest dark hard",
"is_dark": true,
"colors": {
"accent-color": "#a7c080",
"primary-color": "#a7c080",
"warning-color": "#e67e80",
"sidebar-color": "#323d43",
"roomlist-background-color": "#2f383e",
"roomlist-text-color": "#d3c6aa",
"roomlist-text-secondary-color": "#d3c6aa",
"roomlist-highlights-color": "#4b565c",
"roomlist-separator-color": "#4b565c",
"timeline-background-color": "#2b3339",
"timeline-text-color": "#d3c6aa",
"secondary-content": "#d3c6aa",
"tertiary-content": "#d3c6aa",
"timeline-text-secondary-color": "#a7c080",
"timeline-highlights-color": "#4b565c",
"reaction-row-button-selected-bg-color": "#4b565c"
}
},
{
"name": "aXion1337 Dark", #Gruvbox Dark
"is_dark": true,
"colors": {
"accent-color": "#bd93f9",
"primary-color": "#fe8019",
"warning-color": "#fb4934",
"sidebar-color": "#282828",
"roomlist-background-color": "#1d2021",
"roomlist-text-color": "#a89984",
"roomlist-text-secondary-color": "#00ff00",
"roomlist-highlights-color": "#00000030",
"roomlist-separator-color": "#4d4d4d90",
"timeline-background-color": "#282828",
"timeline-text-color": "#ebdbb2",
"secondary-content": "#928374",
"tertiary-content": "#928374",
"quinary-content": "#504945",
"timeline-text-secondary-color": "#a89984",
"timeline-highlights-color": "#00000030",
"reaction-row-button-selected-bg-color": "#689d6a",
"menu-selected-color": "#504945",
"icon-button-color": "#928374",
"accent": "#689d6a",
"alert": "#cc241d",
"username-colors": [
"#cc241d",
"#98971a",
"#d79921",
"#458588",
"#b16286",
"#689d6a",
"#a89984",
"#d65d0e"
@@ -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,22 +15,627 @@ 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_qr_code_login": 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",
"name": "aXion1337 Dark true",
"is_dark": true,
"colors": {
"accent-color": "#ffaf0f",
"primary-color": "#ffaf0f",
"secondary-color": "#ffaf0f"
}
},
{
"name": "Deep Purple",
"is_dark": true,
"colors": {
"accent-color": "#6503b3",
"primary-color": "#368bd6",
"warning-color": "#b30356",
"sidebar-color": "#15171B",
"roomlist-background-color": "#22262E",
"roomlist-text-color": "#A1B2D1",
"roomlist-text-secondary-color": "#EDF3FF",
"roomlist-highlights-color": "#343A46",
"roomlist-separator-color": "#a1b2d1",
"timeline-background-color": "#181b21",
"timeline-text-color": "#EDF3FF",
"timeline-text-secondary-color": "#A1B2D1",
"timeline-highlights-color": "#22262E"
}
},
{
"name": "Discord Dark",
"is_dark": true,
"colors": {
"accent-color": "#747ff4",
"accent": "#747ff4",
"primary-color": "#00aff4",
"warning-color": "#faa81ad9",
"alert": "#faa81ad9",
"sidebar-color": "#202225",
"roomlist-background-color": "#2f3136",
"roomlist-text-color": "#dcddde",
"roomlist-text-secondary-color": "#8e9297",
"roomlist-highlights-color": "#4f545c52",
"roomlist-separator-color": "#40444b",
"timeline-background-color": "#36393f",
"timeline-text-color": "#dcddde",
"secondary-content": "#dcddde",
"tertiary-content": "#dcddde",
"timeline-text-secondary-color": "#b9bbbe",
"timeline-highlights-color": "#04040512",
"reaction-row-button-selected-bg-color": "#4752c4",
"menu-selected-color": "#4752c4",
"focus-bg-color": "#4752c4",
"room-highlight-color": "#4752c4",
"other-user-pill-bg-color": "#4752c4",
"togglesw-off-color": "#72767d"
},
"compound": {
"--cpd-color-theme-bg": "#0019ff",
"--cpd-color-bg-canvas-default": "#2f3136",
"--cpd-color-bg-subtle-secondary": "#2f3136",
"--cpd-color-bg-subtle-primary": "#4f545c52",
"--cpd-color-bg-action-primary-rest": "#dcddde",
"--cpd-color-bg-action-secondary-rest": "#2f3136",
"--cpd-color-bg-critical-primary": "#fd3f3c",
"--cpd-color-bg-critical-subtle": "#745862",
"--cpd-color-bg-critical-hovered": "#fd3f3c",
"--cpd-color-bg-accent-rest": "#4cb387",
"--cpd-color-text-primary": "#dcddde",
"--cpd-color-text-secondary": "#b9bbbe",
"--cpd-color-text-action-accent": "#b9bbbe",
"--cpd-color-text-critical-primary": "#fd3f3c",
"--cpd-color-text-success-primary": "#4cb387",
"--cpd-color-icon-primary": "#dcddde",
"--cpd-color-icon-secondary": "#dcddde",
"--cpd-color-icon-tertiary": "#a7a0a7",
"--cpd-color-icon-accent-tertiary": "#4cb387",
"--cpd-color-border-interactive-primary": "#5d6064",
"--cpd-color-border-interactive-secondary": "#5d6064",
"--cpd-color-border-critical-primary": "#fd3f3c",
"--cpd-color-border-success-subtle": "#4cb387"
}
},
{
"name": "Electric Blue",
"is_dark": false,
"colors": {
"accent-color": "#3596fc",
"primary-color": "#368bd6",
"warning-color": "#ff4b55",
"sidebar-color": "#27303a",
"roomlist-background-color": "#f3f8fd",
"roomlist-text-color": "#2e2f32",
"roomlist-text-secondary-color": "#61708b",
"roomlist-highlights-color": "#ffffff",
"roomlist-separator-color": "#e3e8f0",
"timeline-background-color": "#ffffff",
"timeline-text-color": "#2e2f32",
"timeline-text-secondary-color": "#61708b",
"timeline-highlights-color": "#f3f8fd",
"username-colors": ["#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#9400d3", "#ff1493"],
"avatar-background-colors": ["#cc0000", "#cc6600", "#cccc00", "#00cc00", "#0000cc", "#3b0066", "#7a00b3", "#cc1077"]
},
"compound": {
"--cpd-color-icon-accent-tertiary": "var(--cpd-color-blue-800)",
"--cpd-color-text-action-accent": "var(--cpd-color-blue-900)"
}
},
{
"name": "Everforest dark hard",
"is_dark": true,
"colors": {
"accent-color": "#a7c080",
"primary-color": "#a7c080",
"warning-color": "#e67e80",
"sidebar-color": "#323d43",
"roomlist-background-color": "#2f383e",
"roomlist-text-color": "#d3c6aa",
"roomlist-text-secondary-color": "#d3c6aa",
"roomlist-highlights-color": "#4b565c",
"roomlist-separator-color": "#4b565c",
"timeline-background-color": "#2b3339",
"timeline-text-color": "#d3c6aa",
"secondary-content": "#d3c6aa",
"tertiary-content": "#d3c6aa",
"timeline-text-secondary-color": "#a7c080",
"timeline-highlights-color": "#4b565c",
"reaction-row-button-selected-bg-color": "#4b565c"
}
},
{
"name": "aXion1337 Dark",
"is_dark": true,
"colors": {
"accent-color": "#bd93f9",
"primary-color": "#fe8019",
"warning-color": "#fb4934",
"sidebar-color": "#282828",
"roomlist-background-color": "#1d2021",
"roomlist-text-color": "#a89984",
"roomlist-text-secondary-color": "#00ff00",
"roomlist-highlights-color": "#00000030",
"roomlist-separator-color": "#4d4d4d90",
"timeline-background-color": "#282828",
"timeline-text-color": "#ebdbb2",
"secondary-content": "#928374",
"tertiary-content": "#928374",
"quinary-content": "#504945",
"timeline-text-secondary-color": "#a89984",
"timeline-highlights-color": "#00000030",
"reaction-row-button-selected-bg-color": "#689d6a",
"menu-selected-color": "#504945",
"icon-button-color": "#928374",
"accent": "#689d6a",
"alert": "#cc241d",
"username-colors": [
"#cc241d",
"#98971a",
"#d79921",
"#458588",
"#b16286",
"#689d6a",
"#a89984",
"#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:K692tDSLoftfKmsd4nIc8BzJw9CwXkOc4qSGTw78qF7L7UolTlH456WYeuwXiajDHmsVJCLhxDjCAhGyYUgbEC1mwuX1ZpkJefJkXvkGtQqnVbpS5MLYWt2SMQo+Yuz5L3Qxkop8Y02/U8jFxu5w7LQT71aAJphI1Po6r0wWjCIP0pDLTSUXd5Tk4eLq7o1a8nCrkJxSDQc95sRPTlZ/cfVhBE4xDzDK/CD/Yajv6yUceqUr7j7aBHOlg82+y0HU8xY44B/NtAzphJJf706z0X80Q5Dboeks5Rj9Na0lld5hwcOQPocYO28Tb6RDYOkNOQUELZKoc64JcpYJNyUkxfJl0hjtC1TmtAy1ZljLytnbnc9abNnHWgKcBt9TCcLIJJbZAI2snSHON8uNYpf7+erXx0wHVGxL/WZWEiFlAhnTfNx/Dqr2l0tKTjzRgibSpeA9rBmEm1/2PnJjexRpyewbS2MASGy2C1lfqaAPfjxf,iv:tguaH5u/67ubPSzhrhk3QMkpsE3flvrEZf50U3bcXvc=,tag:KY3T9RcV9KVBKb1SKXCjmw==,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+IFgyNTUxOSBJYUEvbDF0azNsM0RSS3pZ
eG1sNEZKOXAzUVRZWWVTbjlpazJqaVpBRWtBCkE4SW9qVVhxa1dKSXhXV2pSN0lK
VVZET0E2bWZSclkvSzlqdlRxcjVNOEEKLS0tIEZtdm9xRU81WXpkTGQ1UW5HZUl2
UERlYmtRc21PSnFGNjFkZFVVcm9nUTgKuxWcqg1MRWq7Yhp61lHHKPZsdiNeHXSd
72OCNMaUiz58kTKt+tpthUxXlGUlf3y0VA1/KigeA+L5Z2bk/bdMmw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsWDNpQm90aHZLR1MwendM
RC8rTE4rS3laK281THE2Mnp4b2hTdjhQVjFFClRGTExuWVFNVGZGTURWNyt1Qkxy
YnBEaytBTjJqS25zeG5rczVLR256OEUKLS0tIEdBdHNsWit6MjlVUDVSWmZJRjJM
NGN1S3hpNThQSERDZGFtRjBaWi9mYkkKSiRfp0qHxQ+CAKhAjJRhnHl30iteBQuS
Ha8G0sJNHDk/+vg4/3LR+D1hv2i6vbI/Mo0W85GiCpGBu0hajTxgbQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-22T13:49:01Z"
mac: ENC[AES256_GCM,data:wHDBVBTvNycXTKTVBK1MNrGg2C0XufPpNTx9ksp1V5rtfcPvZNbiw8aUcjHt6hNtT3tPPmUt82jwzAbAm3hnPsAVfnue+bB2BZ91AI11tcqr4o+l3djeJODntF5d6TfdIWchtwMisy7bBMtLPS4Vs++JPbP90p/CtCdOxPwh1CQ=,iv:CQ24u8xSpzG3QU4JzzpZIx5cBrMH5RxVPl/S6gLylxU=,tag:f8FdKvtyxz8ks0gV6ct9iQ==,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
@@ -1,22 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: ess-mas-custom-secrets
namespace: matrix
stringData:
values.yaml: ENC[AES256_GCM,data:Vk5TvaBui8gK6ftcRvZA8aEmw+QmWhn6pp9/2T5ndw8YPiggAtpqZszms5yoOxPK2Jy+GAVZ3M1whWFrdqy2WPjCAVEKlg5i7qeC1wKfgjMV2txWRlQjYAu2n4IYxi1aOaFcqzxLGYclAlQh6J0PmSYKtbWZO0nsjjcbXt9AXRgmjjZnIcNEBcQg9DoSew4MiHr5BQ0dsN0hJx96UhtTAVwssDgE1eKlTz2K/VD/xz3Odd/WuFKjThpa4E3p66Azc+4WNuQpxFejUgEg5ZzMvup8GGEzH5L3jBtxdBNmNFn6GNwFfYwq/ohWTXafVTj1mEpwTYBcKEtsmks8fl/qzb6QBobOOPqKTD4chRhv72yeYE7rv9x6530TxNXGUd++jxtQm1G+ZOjje05k3x2i9SQv5CNBM2jSyoh2uktoVyaJLpgJMv35VdyLAWY+nNXD4yj0isI=,iv:7GmbMaMDWY+agQC3Xr9SpOzGPyb82RDCSEyPHaWwMhQ=,tag:FUpe4v9BybS0M+8L7yvB4A==,type:str]
sops:
age:
- recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCUWlDYkY4NVpWNFVHb1Bo
b3FpR0w5NmttcW9IV1RXWENwTnE2NU9tR0hvCnJyNU9xWEpYZWRXVURtTis3aEp2
MmtCZXZXemlKNnZrbW1OL0NlMXlQbzQKLS0tIG81ZXIyd2pGOVlXeEVtRy92RzFp
VU9sa0tOdlBqcnZ2VTVaQWpaN3Y3L0UKUel3SRju6rFbMa9Di++EAci8+fsMjx4X
X8fM/N4jTHerXOBCT/bnD/Tk/2jAcwg9PC3e906VDUdsIZw+xU+grw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-22T10:12:35Z"
mac: ENC[AES256_GCM,data:zMIPahMwgSgF9vdEkp7DyECL3s6IuEHWs0JE1G9A+xYmm0mAFYbzMPwgg6oB57gNpX3EaPovHOOvKlTjZVWUbv892mU63GPi4W5kRxsZya5FyC3PxyZlsvMX9U/F8Hwvo9tymbGYpsGGkxv0doRZvTuKDzj1j1+VO0VVcY2i3Gc=,iv:dq6GtQ0vfQWiew6s6gWjPRZPtQ1rD3cO3aQXPDT0Sos=,tag:7UrcQ8Q420HK/vW01SsRkg==,type:str]
encrypted_regex: ^(data|stringData)$
version: 3.12.2
@@ -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: |
@@ -22,4 +38,69 @@ data:
- '::1/128'
- 'fe80::/10'
- 'fc00::/7'
max_spider_size: 10M
max_spider_size: 10M
retention:
config: |
retention:
enabled: true
default_policy:
min_lifetime: 1d
max_lifetime: 1y
allowed_lifetime_min: 1d
allowed_lifetime_max: 2y
purge_jobs:
- longest_max_lifetime: 3d
interval: 12h
- shortest_max_lifetime: 3d
longest_max_lifetime: 1w
interval: 1d
- shortest_max_lifetime: 1w
interval: 2d
media_retention:
local_media_lifetime: 365d
remote_media_lifetime: 90d
redaction_retention_period: 7d
forgotten_room_retention_period: 28d
user_ips_max_age: 90d
auto_join:
config: |
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
oembed:
config: |
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
+89 -2
View File
@@ -4,7 +4,11 @@ metadata:
name: matrix-stack
namespace: matrix
spec:
interval: 1h
# 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
@@ -43,6 +50,10 @@ spec:
enabled: true
ingress:
host: matrix.axion1337.chat
additional:
oembed:
config: |
oembed_enabled: true
# Matrix Authentication Service braucht eine Subdomain
matrixAuthenticationService:
@@ -55,10 +66,86 @@ spec:
enabled: true
ingress:
host: mrtc.axion1337.chat
# Chart default (20Mi request+limit) OOM-killed the authorisation service after
# ~74 days of uptime (2026-07-28) - too tight for a long-running Go service.
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 128Mi
# Element Web
elementWeb:
enabled: true
image:
registry: rohana.axion1337.de
repository: sorb/threadnet-web
# 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
@@ -0,0 +1,514 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ess-element-web-docs
namespace: matrix
data:
# HTML Index Page
"index.html": |
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Element Desktop Setup - aXion1337.Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
color: #666;
margin-bottom: 40px;
font-size: 1.1em;
}
.section {
margin-bottom: 40px;
}
.section h2 {
color: #667eea;
font-size: 1.5em;
margin-bottom: 20px;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.download-card {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.3s ease;
text-decoration: none;
color: #333;
}
.download-card:hover {
border-color: #667eea;
background: #f0f3ff;
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
}
.download-card .icon {
font-size: 2.5em;
margin-bottom: 10px;
}
.download-card .name {
font-weight: 600;
font-size: 1.1em;
margin-bottom: 5px;
}
.download-card .desc {
font-size: 0.9em;
color: #666;
}
.themes {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
}
.themes h3 {
color: #333;
margin-bottom: 15px;
}
.theme-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.theme-item {
background: white;
padding: 10px;
border-radius: 4px;
text-align: center;
color: #667eea;
font-weight: 500;
border: 1px solid #667eea;
}
.instructions {
background: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 15px;
border-radius: 4px;
margin: 15px 0;
line-height: 1.6;
}
.instructions code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
.support {
text-align: center;
color: #666;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.support a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
.support a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>🎨 Element Desktop Setup</h1>
<p class="subtitle">Automatische Konfiguration mit Custom Themes für aXion1337.Chat</p>
<div class="section">
<h2>📥 Download Setup-Script</h2>
<div class="download-grid">
<a href="element-setup-windows.cmd" class="download-card" download>
<div class="icon">🪟</div>
<div class="name">Windows</div>
<div class="desc">.cmd Datei</div>
</a>
<a href="element-setup-macos.command" class="download-card" download>
<div class="icon">🍎</div>
<div class="name">macOS</div>
<div class="desc">.command Datei</div>
</a>
<a href="element-setup-linux.sh" class="download-card" download>
<div class="icon">🐧</div>
<div class="name">Linux</div>
<div class="desc">.sh Datei</div>
</a>
</div>
<div class="instructions">
<strong>Anleitung:</strong><br><br>
<strong>🪟 Windows:</strong> Datei herunterladen → Doppelklick → Script läuft automatisch<br><br>
<strong>🍎 macOS:</strong> Datei herunterladen → Doppelklick im Finder → Script läuft automatisch<br><br>
<strong>🐧 Linux:</strong><br>
<code>chmod +x element-setup-linux.sh</code><br>
<code>./element-setup-linux.sh</code>
</div>
</div>
<div class="section">
<h2>🎨 Verfügbare Themes</h2>
<div class="themes">
<h3>Automatisch geladen in Element:</h3>
<div class="theme-list">
<div class="theme-item">aXion1337 Dark</div>
<div class="theme-item">Deep Purple</div>
<div class="theme-item">Discord Dark</div>
<div class="theme-item">Electric Blue</div>
<div class="theme-item">Everforest dark hard</div>
<div class="theme-item">Gruvbox Dark</div>
<div class="theme-item">Wal</div>
</div>
</div>
</div>
<div class="section">
<h2>❓ Support</h2>
<p>Für weitere Hilfe besuche: <a href="https://element.io/help" target="_blank">element.io/help</a></p>
<p>🔐 <a href="security.html">Konto-Sicherheit (Passkey/2FA einrichten)</a></p>
</div>
<div class="support">
<p>Element wird automatisch installiert und konfiguriert.<br>
<small>Bei Fragen oder Problemen: <a href="https://element.io/help">Element Support</a></small></p>
</div>
</div>
</body>
</html>
# Security / 2FA setup page
"security.html": |
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Konto-Sicherheit - aXion1337.Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
}
h1 { color: #333; margin-bottom: 10px; font-size: 2.5em; }
.subtitle { color: #666; margin-bottom: 40px; font-size: 1.1em; }
.section { margin-bottom: 40px; }
.section h2 {
color: #667eea;
font-size: 1.5em;
margin-bottom: 20px;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.download-card {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.3s ease;
text-decoration: none;
color: #333;
}
.download-card:hover {
border-color: #667eea;
background: #f0f3ff;
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
}
.download-card .icon { font-size: 2.5em; margin-bottom: 10px; }
.download-card .name { font-weight: 600; font-size: 1.1em; margin-bottom: 5px; }
.download-card .desc { font-size: 0.9em; color: #666; }
.instructions {
background: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 15px;
border-radius: 4px;
margin: 15px 0;
line-height: 1.6;
}
.support {
text-align: center;
color: #666;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
.support a { color: #667eea; text-decoration: none; font-weight: 500; }
.support a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>🔐 Konto-Sicherheit</h1>
<p class="subtitle">Zwei-Faktor-Authentifizierung ist optional - richte sie nur ein, wenn du sie nutzen möchtest.</p>
<div class="section">
<h2>🔑 Einrichten</h2>
<div class="download-grid">
<a href="https://auth.axion1337.chat/if/flow/default-authenticator-webauthn-setup/" class="download-card" target="_blank">
<div class="icon">🔑</div>
<div class="name">Passkey</div>
<div class="desc">WebAuthn / Sicherheitsschlüssel</div>
</a>
<a href="https://auth.axion1337.chat/if/flow/default-authenticator-totp-setup/" class="download-card" target="_blank">
<div class="icon">📱</div>
<div class="name">TOTP</div>
<div class="desc">Authenticator-App</div>
</a>
</div>
<div class="instructions">
<strong>Hinweis:</strong> Du musst bei <code>auth.axion1337.chat</code> eingeloggt sein, damit die
Einrichtung funktioniert. Ohne konfiguriertes Gerät wird beim Login einfach kein zweiter Faktor abgefragt -
2FA ist nie Voraussetzung zum Anmelden.
</div>
</div>
<div class="support">
<p><a href="index.html">← Zurück zum Setup</a></p>
</div>
</div>
</body>
</html>
# README
"README-Element-Setup.md": |
# Element Desktop Setup Scripts
Automatische Konfiguration und Installation von Element Desktop mit Custom Themes für aXion1337.Chat
## 🎨 Verfügbare Themes
- aXion1337 Dark
- Deep Purple
- Discord Dark
- Electric Blue
- Everforest dark hard
- Gruvbox Dark
- Wal
## 🪟 Windows
Herunterladen: `element-setup-windows.cmd` → Doppelklick
## 🍎 macOS
Herunterladen: `element-setup-macos.command` → Doppelklick im Finder
## 🐧 Linux
```bash
chmod +x element-setup-linux.sh
./element-setup-linux.sh
```
Support: https://element.io/help
# Windows Script
"element-setup-windows.cmd": |
@echo off
REM Element Desktop Setup Script for Windows
setlocal enabledelayedexpansion
echo ========================================
echo Element Desktop Konfiguration Setup
echo ========================================
echo.
set APPDATA_PATH=%APPDATA%\Element
set CONFIG_FILE=%APPDATA_PATH%\config.json
if not exist "%APPDATA_PATH%" (
echo Erstelle Element Verzeichnis...
mkdir "%APPDATA_PATH%"
)
echo Erstelle config.json...
(
echo {
echo "configUrl": "https://axion1337.chat/config.json",
echo "brand": "aXion1337.Chat",
echo "default_theme": "aXion1337 Dark",
echo "show_labs_settings": true,
echo "features": {
echo "feature_qr_code_login": true
echo },
echo "setting_defaults": {
echo "custom_themes": []
echo }
echo }
) > "%CONFIG_FILE%"
echo Config erstellt: %CONFIG_FILE%
echo.
echo Ueberpruefen Sie ob Element Desktop installiert ist...
where element >nul 2>nul
if %ERRORLEVEL% == 0 (
echo Starte Element Desktop...
start element
timeout /t 2 >nul
echo Done!
pause
exit /b 0
)
winget list --name "Element" >nul 2>nul
if %ERRORLEVEL% == 0 (
echo WinGet gefunden. Installiere Element...
winget install Element.Element --silent
timeout /t 3 >nul
start element
pause
exit /b 0
)
echo.
echo Element Desktop konnte nicht automatisch installiert werden.
echo Bitte installiere Element Desktop manuell:
echo https://element.io/download
echo.
pause
# macOS Script
"element-setup-macos.command": |
#!/bin/bash
echo "========================================"
echo "Element Desktop Konfiguration Setup"
echo "========================================"
echo ""
CONFIG_DIR="$HOME/Library/Application Support/Element"
CONFIG_FILE="$CONFIG_DIR/config.json"
if [ ! -d "$CONFIG_DIR" ]; then
echo "Erstelle Element Verzeichnis..."
mkdir -p "$CONFIG_DIR"
fi
echo "Erstelle config.json..."
cat > "$CONFIG_FILE" << 'EOF'
{
"configUrl": "https://axion1337.chat/config.json",
"brand": "aXion1337.Chat",
"default_theme": "aXion1337 Dark",
"show_labs_settings": true,
"features": {
"feature_qr_code_login": true
},
"setting_defaults": {
"custom_themes": []
}
}
EOF
echo "Config erstellt: $CONFIG_FILE"
echo ""
echo "Ueberpruefen Sie ob Element Desktop installiert ist..."
if [ -d "/Applications/Element.app" ]; then
echo "Element im Applications Folder gefunden. Starte Element..."
open -a Element
sleep 2
exit 0
fi
if command -v brew &> /dev/null; then
echo "Installiere Element uber Homebrew..."
brew install element --cask
sleep 2
open -a Element
exit 0
else
echo ""
echo "Homebrew nicht gefunden. Bitte installiere zuerst:"
echo "https://brew.sh"
echo ""
echo "Deine config.json wurde erstellt unter:"
echo "$CONFIG_FILE"
echo ""
fi
read -p "Druecke Enter zum Beenden..."
# Linux Script
"element-setup-linux.sh": |
#!/bin/bash
echo "========================================"
echo "Element Desktop Konfiguration Setup"
echo "========================================"
echo ""
CONFIG_DIR="$HOME/.config/Element"
CONFIG_FILE="$CONFIG_DIR/config.json"
if [ ! -d "$CONFIG_DIR" ]; then
echo "Erstelle Element Verzeichnis..."
mkdir -p "$CONFIG_DIR"
fi
echo "Erstelle config.json..."
cat > "$CONFIG_FILE" << 'EOF'
{
"configUrl": "https://axion1337.chat/config.json",
"brand": "aXion1337.Chat",
"default_theme": "aXion1337 Dark",
"show_labs_settings": true,
"features": {
"feature_qr_code_login": true
},
"setting_defaults": {
"custom_themes": []
}
}
EOF
echo "Config erstellt: $CONFIG_FILE"
echo ""
if command -v apt &> /dev/null; then
echo "Installiere Element uber apt..."
sudo apt update && sudo apt install -y element-desktop
element &
exit 0
fi
if command -v dnf &> /dev/null; then
echo "Installiere Element uber dnf..."
sudo dnf install -y element-desktop
element &
exit 0
fi
if command -v pacman &> /dev/null; then
echo "Installiere Element uber pacman..."
sudo pacman -S --noconfirm element-web
element &
exit 0
fi
echo "Element Desktop konnte nicht automatisch installiert werden."
echo "Bitte installiere Element Desktop manuell:"
echo "Ubuntu/Debian: sudo apt install element-desktop"
echo "Fedora/RHEL: sudo dnf install element-desktop"
echo "Arch: sudo pacman -S element-web"
echo ""
echo "Deine config.json wurde erstellt unter:"
echo "$CONFIG_FILE"
@@ -0,0 +1,93 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: element-web-docs
namespace: matrix
spec:
replicas: 1
selector:
matchLabels:
app: element-web-docs
template:
metadata:
labels:
app: element-web-docs
spec:
initContainers:
- name: copy-files
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
mkdir -p /html/docs/setup
cp /config/index.html /html/docs/setup/
cp /config/security.html /html/docs/setup/
cp /config/README-Element-Setup.md /html/docs/setup/
cp /config/element-setup-windows.cmd /html/docs/setup/
cp /config/element-setup-macos.command /html/docs/setup/
cp /config/element-setup-linux.sh /html/docs/setup/
chmod 644 /html/docs/setup/*
volumeMounts:
- name: config
mountPath: /config
- name: html
mountPath: /html
containers:
- name: nginx
image: nginx:1.26-alpine
ports:
- containerPort: 8080
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: nginx.conf
- name: html
mountPath: /usr/share/nginx/html
volumes:
- name: config
configMap:
name: ess-element-web-docs
- name: nginx-conf
configMap:
name: element-web-docs-nginx
- name: html
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: element-web-docs
namespace: matrix
spec:
selector:
app: element-web-docs
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: element-web-docs-nginx
namespace: matrix
data:
nginx.conf: |
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
location /docs/setup/ {
index index.html;
try_files $uri $uri/ =404;
}
location / {
return 404;
}
}
+96 -1
View File
@@ -1,13 +1,108 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# 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
name: matrix-stack
namespace: matrix
patch: |-
- 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
- cert-issuer.yaml
- matrix-certificates.yaml
# Neue Dateien:
- custom-configs/synapse-values.yaml
- custom-configs/element-values.yaml
- custom-configs/mas-secret.yaml
- element-web-docs-configmap.yaml
- element-web-docs-server.yaml
# 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
- apex-ingress.yaml # Custom Apex Ingress für Element Web + Well-Known auf axion1337.chat
- 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
+13
View File
@@ -62,3 +62,16 @@ spec:
kind: ClusterIssuer
dnsNames:
- admin.axion1337.chat
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: turn-axion1337-chat-cert
namespace: matrix
spec:
secretName: turn-axion1337-chat-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- turn.axion1337.chat
+463
View File
@@ -0,0 +1,463 @@
# Default-deny ingress for the matrix namespace, with explicit allow rules per component.
# Egress is intentionally untouched (federation to arbitrary Matrix servers, ACME, SMTP,
# DNS all stay unrestricted).
#
# Lesson learned deploying the authentik namespace's equivalent policy: NetworkPolicy
# filters on the pod's actual container port, not the Service's external port (e.g.
# authentik-server's Service maps 80->9000). Wherever a Service here uses a *named*
# targetPort, this file references that name directly instead of guessing a number -
# Kubernetes resolves it from the pod spec, which is safer than a hardcoded port.
#
# matrix-stack-postgres already effectively has no dedicated chart NetworkPolicy of its
# own (unlike authentik-postgresql's Bitnami one) - the rules below are the only gate.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: matrix
spec:
podSelector: {}
policyTypes:
- Ingress
---
# axion1337.chat (root) -> Element Web
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-element-web
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: element-web
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: element
---
# admin.axion1337.chat -> Element Admin
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-element-admin
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: element-admin
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: http
---
# axion1337.chat/docs/setup -> Element desktop setup docs (our own nginx)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-element-web-docs
namespace: matrix
spec:
podSelector:
matchLabels:
app: element-web-docs
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: 8080
---
# matrix.axion1337.chat AND the well-known delegation both front through haproxy
# (matrix-stack-synapse and matrix-stack-well-known Services both target haproxy's
# named ports, not synapse-main directly).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-haproxy
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: haproxy
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: haproxy-synapse
- protocol: TCP
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.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-mas
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: matrix-authentication-service
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
- podSelector:
matchLabels:
app.kubernetes.io/name: synapse-main
ports:
- protocol: TCP
port: 8080
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: synapse-main
ports:
- protocol: TCP
port: 8081
---
# Synapse itself: reached via haproxy (same namespace), calls from MAS (provisioning),
# metrics scraped by Alloy (monitoring namespace).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-synapse
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: synapse-main
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: haproxy
- podSelector:
matchLabels:
app.kubernetes.io/name: matrix-authentication-service
ports:
- protocol: TCP
port: synapse-http
- protocol: TCP
port: synapse-health
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: synapse-metrics
---
# mrtc.axion1337.chat (Traefik) for the auth handshake, plus Alloy scraping metrics.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-rtc-authorisation-service
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: matrix-rtc-authorisation-service
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
- podSelector:
matchLabels:
app.kubernetes.io/name: matrix-rtc-sfu
ports:
- protocol: TCP
port: http
---
# The SFU: mrtc.axion1337.chat (Traefik) for signalling, Alloy for metrics, and the
# NodePort-exposed WebRTC media ports need to stay open to the internet by design -
# that's the actual point of a TURN/SFU media relay, not a mistake.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-rtc-sfu
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: matrix-rtc-sfu
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: http
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: metrics
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- protocol: TCP
port: 30001
- protocol: UDP
port: 30002
---
# Postgres: only Synapse and MAS need data access; Alloy scrapes the exporter.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-postgres
namespace: matrix
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: synapse-main
- podSelector:
matchLabels:
app.kubernetes.io/name: matrix-authentication-service
- podSelector:
matchLabels:
app.kubernetes.io/name: synapse-backup
ports:
- protocol: TCP
port: 5432
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 9187
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-acme-solver
namespace: matrix
spec:
podSelector:
matchLabels:
acme.cert-manager.io/http01-solver: "true"
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: 8089
# 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.
@@ -0,0 +1,18 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: authentik-apps
namespace: flux-system
spec:
interval: 1m
path: ./apps/authentik
prune: true
sourceRef:
kind: GitRepository
name: flux-system
decryption:
provider: sops
secretRef:
name: sops-age
dependsOn:
- name: infra-apps
@@ -4,4 +4,6 @@ resources:
- gotk-components.yaml
- gotk-sync.yaml
- infra-sync.yaml
- monitoring-sync.yaml
- production-sync.yaml
- authentik-sync.yaml
@@ -0,0 +1,14 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: monitoring-apps
namespace: flux-system
spec:
interval: 10m
path: ./apps/monitoring
prune: true
sourceRef:
kind: GitRepository
name: flux-system
dependsOn:
- name: infra-apps
+490
View File
@@ -0,0 +1,490 @@
# aXion1337.Chat Task List & Meilensteine
**Last Updated**: 2026-07-28
**Statusübersicht**: [✅ 13 Abgeschlossen] [🔄 0 In Progress] [📋 8+ Pending] [🔒 10 Security]
---
## 📊 Status Summary (Quick View)
| Kategorie | Count | Status | Details |
|-----------|-------|--------|---------|
| **Completed** | 13 | ✅ Done | K3S, Flux, ESS, Themes, Desktop, Monitoring, TURN, Authentik (Deploy+Enrollment/Recovery/2FA), Firewall, SSH, coturn Fix, Element Call Fork, NetworkPolicies |
| **In Progress** | 0 | 🔄 — | — |
| **Backlog** | 8+ | 📋 Pending | DB Backups, PostgreSQL Migration, MAS-Template-Link, VP9-Retry |
| **Security Tasks** | 5 | 🔒 Pending | auditd, Kernel hardening, CrowdSec, Falco, WAF |
### Priority Distribution
| Priority | Count | Timeline |
|----------|-------|----------|
| 🔴 **CRITICAL** | 3 | This week |
| 🟠 **HIGH** | 4 | 12 weeks |
| 🟡 **MEDIUM** | 8 | ~1 month |
| 🟢 **LOW** | 4+ | Nice-to-have |
---
## 🗓️ Session-Zusammenfassung 2026-07-27/28 (fortlaufend aktualisiert)
Nach längerer Pause wiederaufgenommen — Mac war neu aufgesetzt, Zugriff (SSH, Kubeconfig,
age-Key, Homebrew/flux/helm/sops/age) komplett wiederhergestellt und dauerhaft in `~/.zshrc`
verankert. Was in dieser Session erledigt wurde:
1. **Authentik Enrollment/Recovery/2FA** (Issue #7 ✅ geschlossen) — siehe Phase 8 unten und
`docs/troubleshooting/README.md`. `matrix-invitation`- und `matrix-recovery`-Flows waren
kaputt bzw. leer, jetzt als Authentik Blueprint (`apps/authentik/authentik-blueprints.yaml`)
deklarativ repariert. E2E mit echten Test-Usern (`clark`, `lucky`) verifiziert.
2. **coturn-Crash behoben** — Liveness-Probe nutzte `netstat` (existiert nicht im Image),
Server killte einen gesunden Prozess seit 88 Tagen, 36.000+ Restarts. Auf `tcpSocket`-Probe
umgestellt, läuft seitdem stabil.
3. **Element Call Fork** (Issue #8 ✅ geschlossen, Release `m6-element-call-fork-complete`) —
1440p/60fps-Defaults, siehe Kapitel 4 in `docs/deployment-guides/04-element-customization.md`.
**Wichtig**: erzwungenes `video_codec: vp9` hat Calls kurzzeitig live komplett kaputt gemacht
(kein Bild/Ton) — sofort zurückgerollt, ohne Codec-Zwang läuft's. Root Cause dafür nicht
abschließend isoliert, nur umgangen.
4. **Identitäts-Aufräumarbeiten**: `sorB`'s Authentik-E-Mail korrigiert (`thorec@hotmail.de`),
MAS OIDC-Link (`upstream_oauth_links`) von `sorB` zeigte fest auf den alten MAS-User
`akadmin`/`@akadmin:axion1337.chat` (Sub-Hash ist stabil über Username-Renames, daher blieb
die Verknüpfung nach dem Rename "akadmin"→"sorB" bestehen) — umgehängt auf `sorb`/
`@sorb:axion1337.chat`. Neue Identität `elbojoloco` angelegt (E-Mail `cfx@riot.8shield.net`),
verknüpft mit dem alten `akadmin`-MAS-User. **Übrig**: ein leeres, unverknüpftes
`@bojeledoggo:axion1337.chat`-Konto (Tippfehler-Artefakt) — User räumt das selbst auf.
5. **NetworkPolicies** (Issue #10 ✅ geschlossen) — siehe "Network Security" Abschnitt unten.
Zwei Live-Incidents beim Rollout (Port-Verwechslungen), beide binnen Minuten live gepatcht
und danach committed. Nebenbei: `matrixRTC`-Authorisation-Service OOM-Fix (20Mi→128Mi).
6. **Element Call Qualität nachgeschärft** — 720p-Zwischen-Simulcast-Layer ergänzt (sonst
harter Sprung von 1440p auf blockiges 360p bei kleinsten Netzwerkschwankungen), und
`video_codec: h264` statt VP8 (klassisches Simulcast wie VP8, kein SVC-Risiko wie bei
VP9, oft hardwarebeschleunigt v.a. auf iOS). Live verifiziert: 7/8 Tracks nativ H.264,
1 sauberer VP8-Fallback. Deployed als `v0.2.3-elementcall-h264`.
7. **Backlog nach Gitea migriert** — restlicher offener Backlog (VP9-Retry, ThreadNet-Web-Bug,
MAS-Template-Link, WAF und 17 weitere Security-/Infra-Punkte) als Issues #11#31 angelegt,
veraltete erledigte Punkte (Authentik Stage 2/E2E-Test/Invite-Links, Hetzner-Firewall,
SSH-Hardening) aus dieser Datei entfernt bzw. als done markiert.
### Offene Punkte
- **VP9-Retry**: vermutete Ursache jetzt bekannt (LiveKit nutzt SVC für vp9/av1, Fork-Code
setzt aber immer Simulcast-Layer) — braucht einen Code-Fix in `buildPublishOptions()`
(`src/livekit/options.ts`) bevor erneut versucht wird. Stattdessen H.264 probiert (siehe
unten) — läuft gut, kein SVC-Risiko, hardwarebeschleunigt auf mehr Geräten.
- **`ThreadNet-Web` Build-Bug**: `scripts/docker-link-repos.sh`/`docker-package.sh` nicht
ausführbar committet + veralteter `matrix-js-sdk#develop`-Pin im Lockfile blockiert
vollständigen Neu-Build des Web-Forks. Noch nicht gefixt, User hat noch nicht final
entschieden ob gewünscht.
- **Verwaistes `@bojeledoggo:axion1337.chat`**: leeres Matrix-Konto ohne OIDC-Link, User räumt
das selbst auf (braucht dafür seinen eigenen Access-Token für die Admin-API).
- **MAS-Template-Link**: 2FA/Passkey-Setup-Links direkt auf `account.axion1337.chat/account/`
statt nur über `docs/setup/security.html` — braucht MAS Custom-Template-Override
(`templates.path`), größerer separater Task.
- Nächste Kandidaten aus den offenen Issues: #6 (DB-Backup, CRITICAL), #9 (PostgreSQL-Migration),
#10 (NetworkPolicies).
---
## 🎯 Next Steps (Priorisiert)
### 🔴 **THIS WEEK CRITICAL**
1.**Authentik Stage 2 abschließen**
- ✅ Manual: OIDC Provider + Application in Authentik UI erstellt
- ✅ Code: `upstream_oauth2_config` in `mas-secret.yaml` eingefügt
- ✅ Code: `passwords: enabled: false` aktiviert
- ✅ Commit: `cdfbf7d` - Enable Authentik OIDC integration in MAS
- ✅ Verified: Login mit Authentik funktioniert (2026-05-15)
- **Status**: COMPLETE
2.**Hetzner Cloud Firewall Optimierte Konfiguration**
- ✅ Ingress: 80/443 + TURN/STUN + RTC Services
- ✅ SSH: Spezifische IPs (port 2248, nicht 22)
- ✅ Default-Deny für nicht-definierte Traffic
- **Status**: COMPLETE (optimiert über Plan)
3.**SSH Hardening**
- ✅ PasswordAuthentication: no (key-only)
- ✅ PermitRootLogin: no (root disabled)
- ✅ MaxAuthTries: 3 (verified 2026-05-15)
- **Status**: COMPLETE
4. **Database Backup Strategy Decision & First Backup**
- ⏳ Decision: CloudNativePG (on K3S) or Hetzner Postgres (managed)?
- ⏳ Setup: Daily automated backups
- ⏳ Setup: Off-site storage (S3 / Storage Box)
- ⏳ Setup: Monthly verified restores
- Est. Time: 23 days
- Priority: CRITICAL (disaster recovery)
- **Status**: NEXT
### 🟠 **NEXT 12 WEEKS HIGH**
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** — erledigt, Closes Issue #8 (2026-07-28), siehe
`docs/deployment-guides/04-element-customization.md` Kapitel 4. **Status**: COMPLETE
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** — 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
- [x] **K3S Cluster aufsetzen** Single-Node auf Hetzner Cloud (49.13.132.245)
- Commit: `initial-setup` (vor Projekt)
- Status: ✅ Läuft
- [x] **Flux CD Installation**
- SOPS + age Encryption
- GitOps Repository konfigurieren
- Commit: `setup-flux` (vor Projekt)
- Status: ✅ Läuft
- [x] **Element Server Suite v26.4.0 Deployment**
- Synapse Homeserver (`matrix.axion1337.chat`)
- Matrix Authentication Service (`account.axion1337.chat`)
- Element Web (`axion1337.chat`)
- Element Admin (`admin.axion1337.chat`)
- MatrixRTC/Element Call (`mrtc.axion1337.chat`)
- Commit: `deploy-ess-matrix-stack`
- Status: ✅ Running
### Phase 2: Core Features
- [x] **7 Custom Element Web Themes**
- aXion1337 Dark, Deep Purple, Discord Dark, Electric Blue, Everforest, Gruvbox, Wal
- Alphabetisch sortiert
- Commit: `add-custom-element-themes`
- Status: ✅ Deployed
- [x] **Element Desktop Setup Scripts** (Windows/macOS/Linux)
- Auto-Download + Install + Config
- Hosted auf `axion1337.chat/docs/setup/`
- Commits: `add-element-desktop-setup-scripts`, `fix-element-setup-script-hosting`
- Status: ✅ Deployed
- [x] **Room Policies**
- Message Retention (1d1y lifecycle)
- Room Publication Rules (allow all)
- Auto-Join Rooms für Onboarding
- Commit: `add-synapse-retention-publication-autojoin`
- Status: ✅ Deployed
### Phase 3: WebRTC & Medienübertragung
- [x] **TURN Server (coturn) für Video-Calls**
- Domain: `turn.axion1337.chat`
- HMAC-Auth mit Shared Secret
- Ports: 3478/udp, 3478/tcp, 5349/tcp, 49152-65535/udp
- Commit: `implement-turn-server-coturn-for-webrtc-video-calls`
- Status: ✅ Deployed
- Manual: DNS A-Record + Firewall-Ports öffnen (noch erforderlich)
### Phase 4: Monitoring & Observability
- [x] **Monitoring Stack Integration**
- Alloy (Grafana Agent) als Collector
- Remote Write zu Selendis (10.0.0.3:9090 Prometheus, :3100 Loki)
- kube-state-metrics, node-exporter DaemonSet
- Commits: `integrate-monitoring-alloy-prometheus-loki`, `fix-prometheus-remote-write-docker`
- Status: ✅ Deployed
### Phase 5: Identity Provider (Authentik)
- [x] **Authentik Stage 1 Deployment**
- HelmRelease v2026.x in `authentik` namespace
- Embedded PostgreSQL + Alloy-compatible
- Cert-Manager für TLS
- Commit: `deploy-authentik-as-identity-provider-for-matrix-stage-1`
- Status: ✅ Deployed
- Manual: Admin-Passwort + OIDC Provider + Application + Enrollment Flow erstellt ✅
- [x] **Authentik Stage 2 MAS Integration**
- ✅ Authentik Admin UI: OIDC Provider erstellt (Authentik)
- ✅ Authentik Admin UI: Application mit Slug `matrix` erstellt
- ✅ Authentik Admin UI: Enrollment Flow mit Invitation Stage konfiguriert
- ✅ Client ID + Secret kopiert
- ✅ MAS `upstream_oauth2_config` mit Client Credentials aktualisiert
-`passwords: enabled: false` aktiviert
- ✅ Commit: `cdfbf7d` - Enable Authentik OIDC integration in MAS
- ✅ Verified: Login mit Authentik funktioniert (2026-05-15)
- Status: ✅ Deployed & Verified
### Phase 6: Dokumentation
- [x] **Deployment Guides erstellen**
- 5 Markdown-Dateien in `docs/deployment-guides/`
- Chronologisch geordnet
- Troubleshooting + Best Practices
- Commit: `add-comprehensive-deployment-configuration-documentation`
- Status: ✅ Deployed
- [x] **Gitea Wiki erstellen**
- Home.md mit Navigation
- Alle Deployment Guides in Root
- Operations + Archive Dokumentation
- Wiki Branch gepusht zu rohana.axion1337.de
- Status: ✅ Live
- [x] **Gitea Issues & Project Board**
- 8 Issues erstellt (#3-#10): 4 CRITICAL + 4 HIGH
- Priority Labels: critical, high
- Area Labels: authentik, security, database, infrastructure, element
- Status: ✅ Tracking
### Phase 7: Infrastructure Security (Critical)
- [x] **Hetzner Cloud Firewall Configuration**
- SSH: Spezifische IPs (port 2248)
- HTTP/HTTPS: Any IPv4/IPv6
- TURN/STUN: WebRTC Ports
- RTC Services: SFU + Auth Ports
- Status: ✅ Optimiert & Deployed
- [x] **SSH Hardening**
- PasswordAuthentication: no (key-only)
- PermitRootLogin: no
- MaxAuthTries: 3
- Verified: 2026-05-15
- Status: ✅ Complete
---
## 🔄 In Progress / Blocked
**None** Alle CRITICAL Tasks erledigt! Nächster Focus: Database Backups
### Phase 8: Authentik Enrollment/Recovery/MFA Fix (2026-07-27)
- [x] **matrix-invitation Flow repariert** fehlende Write/Password/Login-Stages ergänzt, Reihenfolge korrigiert, als Authentik Blueprint (`apps/authentik/authentik-blueprints.yaml`) reproduzierbar gemacht
- [x] **matrix-invitation-prompt** 16 fehlerhafte `validation_policies` entfernt (crashten mit `AnonymousUser`/`NoneType`-Fehlern)
- [x] **Redirect-Stage** Flow endet jetzt auf `axion1337.chat` statt in der `/if/user/`-Sackgasse (blockiert für `type=external`)
- [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`
- [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)
---
## 📋 Backlog (Weitere Aufgaben)
**Ab 2026-07-28 in Gitea-Issues gepflegt statt hier** (eine Quelle der Wahrheit) — offene Issues:
[#6](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/6) DB-Backup-Strategie,
[#9](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/9) Externe PostgreSQL-Migration,
[#11](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/11)[#31](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/31)
(VP9-Retry, ThreadNet-Web-Build-Bug, MAS-Template-Link, WAF, Media-PVC-Backups, Pod Security
Admission, Federation-Allowlist, Mjolnir/Draupnir, Content-Scanner, External-Secrets,
Renovate/Trivy, Security-Advisory-Monitoring, automountServiceAccountToken,
unattended-upgrades, K3s-API-Security, auditd, Kernel-Hardening, Lynis, CrowdSec, Falco).
Die detaillierten Beschreibungen unten sind das historische Original, aus dem die Issues
entstanden sind — nicht mehr getrennt pflegen, stattdessen die Issues aktuell halten.
### Element Call Enhancement
- [x] **Element Call Fork für Custom Constraints** (2026-07-28, Closes #8)
- Fork: `rohana.axion1337.de/sorb/threadnet-call` (basiert auf `emmick4/element-call:livekit`,
das den noch nicht gemergten Upstream-PR element-hq/element-call#3736 enthält —
config-driven `media_quality`, keine Custom-Logik nötig)
- Defaults angehoben: Video bis 1440p/60fps (~8 Mbps), Screen-Share 1440p/30fps (~6 Mbps).
Das sind Startwerte, keine harten Limits — Nutzer können in den Settings weiter hochdrehen.
- **Incident (2026-07-28)**: Erster Deploy (`v0.2.0`, mit `video_codec: vp9` erzwungen) hat
Calls komplett kaputt gemacht (kein Bild/Ton), obwohl LiveKit-Server-Logs den
Codec-Regression-Fallback auf VP8 als erfolgreich zeigten — Root Cause nicht abschließend
isoliert. Sofort auf `v0.1.0` zurückgerollt, dann `v0.2.1` ohne erzwungenen Codec (Standard
VP8) mit denselben 1440p/60fps-Werten deployed und vom Nutzer live bestätigt: funktioniert.
VP9-Präferenz vorerst fallengelassen, siehe Backlog.
- Rauschunterdrückung: nur clientseitige WebRTC-Standardtoggles (echoCancellation/
noiseSuppression/autoGainControl), kommt kostenlos mit derselben PR. **Bewusst kein**
server-seitiges ML-Noise-Cancellation (LiveKit Agents + DTLN/RNNoise) — laut LiveKits
eigener Doku ist das für Mensch-zu-Mensch-Calls der falsche Ansatz (nur für AI-Voice-Agents
gedacht, kein Standard-Pfad um bereinigtes Audio an andere Teilnehmer zurückzugeben).
- Well-Known/`org.matrix.msc4143.rtc_foci`-Delegation war schon vom ESS-Chart korrekt
automatisch konfiguriert — kein Handlungsbedarf trotz anderslautendem Issue-Text.
- **Deployment-Ansatz geändert**: `sorb/ThreadNet-Web` (der Element-Web-Fork) hat einen
vorbestehenden, unabhängigen Build-Bug (siehe unten) und ließ sich nicht komplett neu
bauen. Stattdessen: nur der `/app/widgets/element-call/`-Ordner im bereits laufenden
`threadnet-web:v0.1.0`-Image ausgetauscht → neues Image
`rohana.axion1337.de/sorb/threadnet-web:v0.2.0-elementcall-mediaquality`.
- Verifiziert: `media_quality` live auf `axion1337.chat/widgets/element-call/config.json`.
- **Gefunden, nicht gefixt**: `ThreadNet-Web` lässt sich aktuell nicht komplett neu bauen
`scripts/docker-link-repos.sh`/`docker-package.sh` sind im Repo nicht ausführbar
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/Dedizierte PostgreSQL-Migration** → [Issue #9](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/9)
(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)
- Default-Deny Ingress (egress left untouched) für `matrix` UND `authentik` namespaces,
per-Komponente Allow-Regeln in `apps/authentik/networkpolicy.yaml` und
`apps/production/networkpolicy.yaml`. Rollout: authentik zuerst als Pilot, dann matrix.
- Empirisch verifiziert, dass K3s' eingebauter NetworkPolicy-Controller tatsächlich
durchsetzt (Testnamespace, Timeout- statt Refused-Verhalten unter Deny-Policy).
- **Zwei Live-Incidents beim Rollout, beide binnen Minuten behoben**:
1. `authentik-server`: Regel erlaubte Service-Port 80/443, aber NetworkPolicy filtert
auf dem tatsächlichen Container-Port (9000/9443 nach kube-proxy-DNAT) — 502 auf
`auth.axion1337.chat`, sofort korrigiert.
2. `matrix-authentication-service`: Regel erlaubte Synapse nur auf Port 8081, aber
Synapse ruft `/oauth2/introspect` tatsächlich auf **Port 8080** — jede
authentifizierte Anfrage (inkl. `/sync`) scheiterte mit 503, alle Clients zeigten
"Verbindung unterbrochen". Live gepatcht, dann committed.
- Lehre für zukünftige NetworkPolicies in diesem Repo: wo immer ein Service benannte
Ports (`targetPort: <name>`) nutzt, diese direkt in der Policy referenzieren statt
Portnummern zu raten — schließt genau diese Fehlerklasse aus.
- Nebenbefund (unabhängig von NetworkPolicies): `matrixRTC`-Authorisation-Service hatte
ein 20Mi-Memory-Limit (Chart-Default), OOM-gekillt nach ~74 Tagen Uptime während der
Verifikations-Calls — auf 64Mi/128Mi angehoben.
- `coturn` (hostNetwork) bewusst ausgenommen — NetworkPolicy greift dort nicht.
- `authentik-postgresql`'s Bitnami-Chart-Policy (Port 5432, quelloffen) bewusst nicht
angefasst/dupliziert, da Helm-verwaltet.
- [ ] **Pod Security Admission (Restricted)** → [Issue #16](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/16)
### Federation & Access Control
- [ ] **Federation-Allowlist oder Closed Federation** → [Issue #17](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/17)
### Moderation & Anti-Abuse
- [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 vs. SOPS-Setup** → [Issue #20](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/20)
### Image & Dependency Management
- [ ] **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
- [ ] **automountServiceAccountToken deaktivieren wo nicht benötigt** → [Issue #23](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/23)
---
## 🔒 Security Hardening (Host & Cluster Level)
### Host OS Layer (Ubuntu/Debian)
- [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.**
- [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** → [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)
---
## 🎯 Versionierung
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
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
- External PostgreSQL migration (#9)
### 🟡 MEDIUM (do within 1 month)
- 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)
- External-Secrets upgrade
- Advanced federation rules
---
## 📝 Notes & Decision Points
### Database: CloudNativePG vs. Hetzner Postgres
- **CloudNativePG**: Open-source, runs on K3S, full control
- **Hetzner Postgres**: Managed, backups included, less ops overhead
- **Decision**: Recommend CloudNativePG for now (cost-effective), migrate to Hetzner later if operational overhead too high
### Federation: Allowlist vs. Closed?
- **Allowlist**: Default federation with all public servers, can be attacked
- **Closed**: Only federate with trusted servers (higher security, lower interop)
- **Decision**: Depends on user intent. For now: allow all, add Mjolnir for abuse protection
### Security Framework
- **Layers**: Perimeter (Firewall) → Host (SSH, auditd, hardening) → Cluster (NetworkPolicies, PSA, Falco) → App (Rate-limits, Mjolnir)
- **Approach**: Implement incrementally, test after each layer
---
## 🔗 Related Documentation
- `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+2 + Enrollment/Recovery/2FA)
- `docs/deployment-guides/03-monitoring-integration.md` Monitoring
- `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-07-30
@@ -0,0 +1,54 @@
# TURN Server (coturn) für WebRTC Video-Calls
**Status**: ✅ Vollständig deployed
**Domain**: `turn.axion1337.chat`
**Public IP**: `49.13.132.245`
## Problem & Lösung
Videocalls scheitern mit DTLS-Timeout bei Clients hinter NAT/Firewall. **Lösung**: coturn als TURN-Relay.
## Architektur
Client A ──→ coturn (turn.axion1337.chat) ──→ Client B
- **Ports**: 3478/udp, 3478/tcp, 5349/tcp, 49152-65535/udp
- **Auth**: HMAC-basiert mit Shared Secret zwischen coturn + Synapse
- **Deployment**: K3S Deployment mit `hostNetwork: true`
## Dateien (in `apps/production/`)
| Datei | Inhalt |
|-------|--------|
| `coturn.yaml` | ConfigMap + Deployment + Service |
| `coturn-secret.yaml` | SOPS-Secret: `TURN_SECRET` |
| `custom-configs/synapse-values.yaml` | TURN URIs + shared secret |
| `matrix-certificates.yaml` | cert-manager Cert für `turn.axion1337.chat` |
## DNS & Firewall (manuell)
```
DNS A-Record: turn.axion1337.chat → 49.13.132.245
Firewall (K3S Host):
ufw allow 3478/udp
ufw allow 3478/tcp
ufw allow 5349/tcp
ufw allow 49152:65535/udp
```
## Verifikation
```bash
# Pod läuft?
kubectl get pods -n matrix -l app=coturn
# Certificate ready?
kubectl get certificate -n matrix | grep turn
# Extern testen
docker run -it instrumentisto/coturn \
turnutils_uclient -v -T -u test -w test turn.axion1337.chat
```
**Weitere Details**: Siehe vollständige Dokumentation oben.
@@ -0,0 +1,109 @@
# Authentik als Identity Provider für Matrix
**Status**: ✅ Deployed (Stage 1 + Stage 2 + Enrollment/Recovery/2FA, Closes Issue #7)
**Domain**: `auth.axion1337.chat`
## Überblick
Authentik = OIDC Provider für MAS → Zentrales Login + Einladungs-basierte Registrierung.
## Stage 1: Authentik Deployment
**Dateien** (in `apps/authentik/`):
- `namespace.yaml`, `helm-repo.yaml`, `authentik-secret.yaml` (SOPS)
- `authentik.yaml` (HelmRelease v2026.x + embedded Postgres)
- `certificate.yaml`, `ingress.yaml`
**Flux Kustomization**: `clusters/matrix/flux-system/authentik-sync.yaml`
## Deployment-Schritte
1. **DNS A-Record**: `auth.axion1337.chat → 49.13.132.245`
2. **Pods hochfahren**: `kubectl get pods -n authentik -w`
3. **Authentik UI**: `https://auth.axion1337.chat/if/flow/initial-setup/` → Admin-Passwort setzen
4. **OIDC Provider**: Admin UI → OIDC Provider erstellen
5. **Application**: Slug `matrix` (wichtig für Issuer URL!)
6. **Redirect URIs**:
- `https://account.axion1337.chat/upstream/callback/01KQDJTR1ZVTG8JQ220F5BNBFZ`
- Post-logout: `https://axion1337.chat`
7. **Client ID + Secret kopieren**
## Stage 2: MAS Integration
1. Decrypt: `sops --decrypt --in-place apps/production/custom-configs/mas-secret.yaml`
2. `upstream_oauth2_config` + `passwords-config` Blöcke hinzufügen
3. Encrypt: `sops --encrypt --in-place ...`
4. Commit & Push
5. **WICHTIG**: `passwords: enabled: false` erst nach OIDC-Test!
## Einladungs-Links
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.
@@ -0,0 +1,52 @@
# Monitoring: Alloy → Prometheus/Loki auf Selendis
**Status**: ✅ Vollständig deployed
**Remote Storage**: `10.0.0.3:9090` (Prometheus), `10.0.0.3:3100` (Loki)
## Überblick
Alloy (Grafana Agent) sammelt Metriken & Logs vom K3S-Cluster und schickt sie zu Prometheus/Loki auf Selendis.
## Komponenten
| Komponente | Rolle |
|-----------|-------|
| **Alloy** | Metrics & Logs Collector |
| **kube-state-metrics** | Kubernetes Object Status |
| **node-exporter** | Host Metrics (CPU, Memory, Disk) |
| **Prometheus** (Selendis) | Metrics Ingestion |
| **Loki** (Selendis) | Logs Ingestion |
## Dateien (in `apps/monitoring/`)
- `namespace.yaml`
- `helm-repos.yaml` (prometheus-community, grafana)
- `kube-state-metrics.yaml`, `node-exporter.yaml`
- `alloy-config.yaml` (River config with scrape targets + remote write)
- `alloy.yaml` (HelmRelease)
## Scrape Targets
Alloy scraped:
- **Flux Controllers** (flux-system ns, port 8080)
- **kube-state-metrics** (monitoring:8080)
- **node-exporter** (monitoring:9100)
- **Synapse** (matrix.axion1337.chat:9000)
Alle Remote Write zu `10.0.0.3:9090` (Prometheus) + `10.0.0.3:3100` (Loki).
## Troubleshooting
```bash
# Check Alloy Logs
kubectl logs -n monitoring -l app.kubernetes.io/name=alloy
# Check Prometheus remote write
curl http://10.0.0.3:9090/api/v1/query?query=up
# Loki test
curl -s http://10.0.0.3:3100/loki/api/v1/query_range?query=%7B%7D | jq .
```
---
**Weitere Details**: Siehe Kapitel 3.
@@ -0,0 +1,113 @@
# Element Web Customization: Themes, Desktop-Apps, Admin
**Status**: ✅ Vollständig deployed
**Domains**: `axion1337.chat` (Web), `/docs/setup` (Scripts)
## 1. Custom Themes (17 Stück, Stand 2026-08-06)
⚠️ 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`
**Anwendung (User)**: Settings → Appearance → Colour theme
## 2. Desktop-Setup-Scripts
| System | Datei |
|--------|-------|
| Windows | `element-setup-windows.cmd` (Doppelklick) |
| macOS | `element-setup-macos.command` (Doppelklick) |
| Linux | `element-setup-linux.sh` (bash) |
**Was die Scripts tun**:
1. config.json erstellen mit `configUrl: "https://axion1337.chat/config.json"`
2. Element installieren (WinGet / Homebrew / apt/dnf/pacman)
3. Element starten (auto-config laden)
**Download**: `https://axion1337.chat/docs/setup/`
## 3. Element Admin-Panel
**URL**: `https://admin.axion1337.chat`
- User verwalten
- Room durchsuchen
- Server-Statistiken
**Konfiguration**: `apps/production/element-server-suite.yaml` (ESS Chart)
## 4. Element Call Fork (Video/Audio-Qualität)
**Status**: ✅ Deployed (2026-07-28, Closes Issue #8)
- Fork: `rohana.axion1337.de/sorb/threadnet-call` (basiert auf `emmick4/element-call:livekit`,
enthält den noch nicht gemergten Upstream-PR element-hq/element-call#3736 mit
config-driven `media_quality` — kein Custom-Code nötig)
- Defaults angehoben: Kamera bis **1440p/60fps** (~8 Mbps), Screen-Share **1440p/30fps**
(~6 Mbps). Startwerte, keine harten Limits — Nutzer können in Settings weiter hochdrehen.
- Rauschunterdrückung: clientseitige WebRTC-Standardtoggles (Echo/Noise/Gain), passend zu
LiveKits eigener Empfehlung für Mensch-zu-Mensch-Calls. Bewusst **kein** server-seitiges
ML-Noise-Cancellation (siehe `docs/TASKS.md` Backlog).
- **Incident (2026-07-28)**: Erster Versuch mit erzwungenem `video_codec: vp9` hat Calls
komplett kaputt gemacht (kein Bild/Ton). Sofort zurückgerollt. Vermutete Ursache: LiveKit
nutzt für vp9/av1 SVC statt klassischem Simulcast, `buildPublishOptions()` im Fork setzt
aber immer Simulcast-Layer — Code-Fix nötig, bevor vp9 erneut versucht wird (Backlog).
- **720p-Zwischen-Layer ergänzt** (`simulcast_layers`) — ohne eigene Definition fiel die
Übertragung bei kleinsten Netzwerkschwankungen direkt von 1440p auf blockiges 360p, jetzt
sanftere Abstufung über 720p.
- **H.264 statt VP8** (2026-07-28) — nutzt wie VP8 klassisches Simulcast (kein SVC-Risiko wie
bei VP9), zusätzlich auf vielen Geräten (v.a. iOS/Safari) hardwarebeschleunigt. Live
verifiziert: 7 von 8 Video-Tracks liefen über H.264, 1 fiel sauber auf den VP8-Backup-Codec
zurück (kein Ausfall). Deployed als `v0.2.3-elementcall-h264`.
- Deployt als `rohana.axion1337.de/sorb/threadnet-web:v0.2.1-elementcall-noquotavp9` — nur
der `/app/widgets/element-call/`-Ordner im bestehenden `v0.1.0`-Image ausgetauscht, da
`ThreadNet-Web` einen vorbestehenden Build-Bug hat (siehe unten).
- Config live prüfbar: `https://axion1337.chat/widgets/element-call/config.json`
**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
| Datei | Ort |
|-------|-----|
| Custom Themes | `element-values.yaml` ConfigMap |
| Setup-Scripts | `element-web-docs-configmap.yaml` |
| Docs Server | `element-web-docs-server.yaml` (nginx) |
| Ingress | `apex-ingress.yaml` (`/docs/setup/` route) |
---
**Weitere Details**: Siehe Kapitel 4.
@@ -0,0 +1,82 @@
# Room Policies: Retention, Publication, Auto-Join
**Status**: ✅ Vollständig deployed
**Konfiguration**: `apps/production/custom-configs/synapse-values.yaml`
## 1. Message Retention
Alte Nachrichten automatisch löschen (Speicher sparen, DSGVO).
```yaml
retention:
enabled: true
default_policy:
min_lifetime: 1d # Messages bleiben ≥1d
max_lifetime: 1y # Messages gelöscht nach 1 Jahr
media_retention:
local_media_lifetime: 365d # 1 Jahr
remote_media_lifetime: 90d # 90 Tage
redaction_retention_period: 7d # Gelöschte Messages noch 7d sichtbar
```
## 2. Room Publication Rules
Kontrollieren welche Rooms im öffentlichen Directory sichtbar sind.
```yaml
room_list_publication_rules:
- user_id: "*" # Alle User
action: allow # dürfen ihre Rooms publishen
```
**Alternativ (restrictiv)**: Nur Admins publishen
```yaml
room_list_publication_rules:
- user_id: "@admin:axion1337.chat"
action: allow
- user_id: "*"
action: deny
```
## 3. Auto-Join Rooms
Neue User automatisch in bestimmte Rooms hinzufügen (Onboarding).
```yaml
auto_join_rooms:
- "!announcements:axion1337.chat"
- "!rules:axion1337.chat"
auto_join_rooms_for_guests: false # Nur registered users
```
**Room ID finden**: Element Settings → Advanced → Room ID
## Deployment
```bash
# Edit ConfigMap
kubectl apply -f apps/production/custom-configs/synapse-values.yaml
# Synapse neustarten
kubectl rollout restart deployment -n matrix matrix-stack-synapse
# Verify
kubectl logs -n matrix -l app.kubernetes.io/name=synapse | grep -i "retention\|publication"
```
## Best Practices
**Privater Server**:
- max_lifetime: 1y (großzügig)
- action: allow (alle publishen)
- auto_join_rooms: announcements + rules
**Öffentlicher Server (DSGWR)**:
- max_lifetime: 90d (kurz)
- action: deny (nur Admins)
- auto_join_rooms: [] (keine Zwangs-Rooms)
---
**Weitere Details**: Siehe Kapitel 5.
@@ -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.
+125
View File
@@ -0,0 +1,125 @@
# aXion1337.Chat Deployment & Konfiguration Dokumentation
Diese Dokumentation beschreibt die Einrichtung und Konfiguration des Matrix-Homeservers für **axion1337.chat** mit Element Server Suite (ESS) v26.4.0 auf K3S mit Flux CD GitOps.
## 📋 Übersicht Deployment-Reihenfolge
Die Implementierungen wurden in dieser Reihenfolge durchgeführt. Für neue Setups sollten Sie dieser Abfolge folgen:
| # | 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` | ✅ 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) |
---
## 🚀 Quick Start für neue Deployment
Siehe die einzelnen Dokumentationen für detaillierte Anleitung.
---
## 🏗️ Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────────┐
│ Element Web (Apex) │
│ axion1337.chat (HTTP/TLS) │
└──────────────────────┬──────────────────────────────────────┘
┌─────────────┼─────────────┐
│ │ │
┌────▼────┐ ┌─────▼──────┐ ┌──▼────────┐
│ MAS │ │ Well-Known │ │Docs/Setup │
│account. │ │matrix/* │ │/setup │
│axion1337 │ │ │ │ │
└────┬────┘ └────────────┘ └───────────┘
┌────▼────────────────┐
│ Authentik OIDC │
│ auth.axion1337.chat │
│ (Identity Provider) │
└─────────────────────┘
┌────▼────────────────┐
│ Synapse Matrix │
│ matrix.axion1337.chat│
│ (Homeserver) │
└──────────────────────┘
```
---
## 🔑 Kritische Werte & Konfigurationen
### Domains
- **Apex**: `axion1337.chat` (Element Web)
- **Matrix Synapse**: `matrix.axion1337.chat`
- **MAS**: `account.axion1337.chat`
- **Authentik**: `auth.axion1337.chat`
- **TURN Server**: `turn.axion1337.chat`
### Externe Services
- **K3S Host IP**: `49.13.132.245`
- **Monitoring Host**: `10.0.0.3` (Selendis)
---
## 📚 Dokumente im Detail
### [01-turn-server-setup.md](01-turn-server-setup.md)
STUN/TURN Server für WebRTC Media Relay (Video-Calls).
### [02-authentik-identity-provider.md](02-authentik-identity-provider.md)
Authentik als OIDC Provider für Matrix. Registrierung via Einladungs-Links.
### [03-monitoring-integration.md](03-monitoring-integration.md)
Alloy → Prometheus/Loki Monitoring Integration.
### [04-element-customization.md](04-element-customization.md)
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
Alle Dokumentationen enthalten Troubleshooting-Sektionen für häufige Probleme.
+56 -1
View File
@@ -75,4 +75,59 @@ flux get helmreleases -n matrix --watch
# Zeigt, wie die Pods hochfahren:
kubectl get pods -n matrix -w
```
Sobald alle Pods auf `Running` stehen und die Zertifikate über Let's Encrypt validiert wurden (`kubectl get certificate -n matrix`), ist dein Matrix-Stack unter `https://axion1337.chat` erreichbar.
Sobald alle Pods auf `Running` stehen und die Zertifikate über Let's Encrypt validiert wurden (`kubectl get certificate -n matrix`), ist dein Matrix-Stack unter `https://axion1337.chat` erreichbar.
---
## 🔁 Recovery: lokalen age-Key wiederherstellen (Server läuft bereits)
Anders als Schritt 2 oben (neuen Key **erzeugen**) — falls der Server bereits läuft und nur der
lokale Rechner den age-Key verloren hat (z.B. nach einer Neuinstallation), lässt sich der
**bestehende** Private Key direkt aus dem Cluster zurückholen, ohne einen neuen zu generieren
(das würde `.sops.yaml` und alle bereits verschlüsselten Secrets ungültig machen):
```bash
mkdir -p ~/.age
kubectl get secret sops-age -n flux-system -o jsonpath='{.data.age\.agekey}' | base64 -d > ~/.age/keys.txt
chmod 600 ~/.age/keys.txt
# Public Key zur Kontrolle gegen .sops.yaml abgleichen:
grep 'public key:' ~/.age/keys.txt
grep 'age:' .sops.yaml
```
Voraussetzung: laufender Kubeconfig-Zugriff auf den Cluster (siehe Schritt 1 oben — auch das
ist reines Zurückkopieren, kein Neu-Erzeugen).
**Bekannte Schwachstelle**: Dieser Key existiert aktuell nur an zwei Orten — im
`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.
---
## 🌐 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.

Some files were not shown because too many files have changed in this diff Show More