chore(coturn): automated TURN shared-secret rotation #46

Closed
sorb wants to merge 76 commits from turn-secret-rotation-20260801-020001 into main
61 changed files with 3314 additions and 408 deletions
Vendored
BIN
View File
Binary file not shown.
+28 -10
View File
@@ -16,11 +16,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
zsh \
sudo \
openssh-client \
gosu \
&& rm -rf /var/lib/apt/lists/*
# Install kubectl
RUN curl -fsSLo /usr/share/keyrings/kubernetes-archive-keyring.gpg https://packages.cloud.google.com/apt/doc/apt-key.gpg && \
echo "deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main" | tee /etc/apt/sources.list.d/kubernetes.list && \
# Install kubectl (apt.kubernetes.io was deprecated/shut down by Google in 2023;
# pkgs.k8s.io is the current community-owned repo, versioned per k8s minor release)
RUN mkdir -p /etc/apt/keyrings && \
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.34/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.34/deb/ /" | tee /etc/apt/sources.list.d/kubernetes.list && \
apt-get update && apt-get install -y kubectl && \
rm -rf /var/lib/apt/lists/*
@@ -30,9 +33,10 @@ RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | b
# Install Flux CLI
RUN curl -s https://fluxcd.io/install.sh | bash
# Install sops
RUN SOPS_VERSION=$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | grep tag_name | cut -d '"' -f 4) && \
curl -sL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64 && \
# Install sops (arch resolved at build time, same reasoning as the Docker CLI step below)
RUN SOPS_ARCH=$(dpkg --print-architecture) && \
SOPS_VERSION=$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | grep tag_name | cut -d '"' -f 4) && \
curl -sL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${SOPS_ARCH} && \
chmod +x /usr/local/bin/sops
# Install age
@@ -40,17 +44,31 @@ RUN apt-get update && apt-get install -y age && \
rm -rf /var/lib/apt/lists/*
# Install Docker CLI (for interacting with Docker daemon)
# arch is resolved at build time so this works on both amd64 (cloud/CI) and arm64 (Apple Silicon) hosts
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
apt-get update && apt-get install -y docker-ce-cli && \
rm -rf /var/lib/apt/lists/*
# Create a non-root user 'vscode' for development
RUN useradd -m -s /bin/bash -G docker vscode && \
# groupadd is needed because only the Docker CLI (not the daemon) is installed above,
# so the 'docker' group is never created as a package side effect
RUN groupadd docker && \
useradd -m -s /bin/zsh -G docker vscode && \
echo "vscode ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/vscode
# Install oh-my-zsh for better shell experience
RUN su - vscode -c "sh -c '$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)' '' --unattended"
RUN curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o /tmp/install-omz.sh && \
su - vscode -c "sh /tmp/install-omz.sh --unattended" && \
rm /tmp/install-omz.sh
# Entrypoint runs as root to reconcile the docker group's GID against the mounted
# socket (see docker-init.sh), then drops to 'vscode' for the actual session/command.
# Stays root-owned at the PID 1 level; VS Code's own `docker exec -u vscode` sessions
# and the entrypoint's `gosu vscode` both end up correctly grouped either way.
COPY docker-init.sh /usr/local/bin/docker-init.sh
RUN chmod +x /usr/local/bin/docker-init.sh
USER vscode
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/docker-init.sh"]
CMD ["/bin/zsh"]
+40 -2
View File
@@ -91,7 +91,7 @@ Der Container mounted `~/.age` automatisch. Setze die Umgebungsvariable:
```bash
# Im Container-Terminal (SOPS_AGE_KEY_FILE ist bereits automatisch gesetzt!)
# Jetzt kannst du Secrets bearbeiten (wird transparent ver-/entschlüsselt):
sops apps/production/custom-configs/mas-secrets.sops.yaml
sops apps/production/custom-configs/mas-secret.yaml
```
### Schritt 3: VSCode Integration (optional)
@@ -130,7 +130,7 @@ kubectl get pods -n matrix
flux get helmreleases -A
# Secrets bearbeiten (mit verschlüsselung)
sops apps/production/custom-configs/mas-secrets.sops.yaml
sops apps/production/custom-configs/mas-secret.yaml
# FluxCD Sync erzwingen
flux reconcile kustomization production-apps --with-source
@@ -193,6 +193,44 @@ Siehe `README.md` → **Issue 3**. Kurz:
- `wellKnownDelegation: enabled: false` setzen
- Oder `.well-known/matrix/server` manuell auf `elementWeb` weiterleiten
## ⚠️ Wartungshinweis: Warum dieser Container regelmäßig getestet werden muss
Der Dockerfile installiert mehrere Tools über externe apt-Repos und Install-Skripte
(`pkgs.k8s.io`, `download.docker.com`, GitHub-Releases, `fluxcd.io`/`ohmyzsh.sh`
Installer). **Diese Quellen sind nicht unter unserer Kontrolle und können jederzeit
brechen** — genau das ist am 2026-07-28 passiert: der Container konnte seit
Fertigstellung nie erfolgreich gebaut werden, ohne dass es jemand bemerkt hat, weil
niemand ihn zwischenzeitlich tatsächlich gebaut hat. Gefundene und behobene Probleme:
| # | Problem | Ursache | Fix |
|---|---------|---------|-----|
| 1 | `apt.kubernetes.io` → `404 Not Found` | Google hat das alte Kubernetes-apt-Repo 2023 abgeschaltet | Umgestellt auf das offizielle Nachfolge-Repo `pkgs.k8s.io` (versioniert pro k8s-Minor-Version, aktuell `v1.34`) |
| 2 | `docker-ce-cli` "has no installation candidate" auf Apple Silicon | Repo-Zeile hatte `arch=amd64` hartkodiert, Build lief aber auf arm64 | `arch=$(dpkg --print-architecture)` zur Build-Zeit ermitteln |
| 3 | `useradd: group 'docker' does not exist` | Nur die Docker-**CLI** wird installiert (kein Daemon), daher legt kein Paket die `docker`-Gruppe automatisch an | `groupadd docker` explizit vor `useradd` |
| 4 | oh-my-zsh-Install schlägt mit Quoting-Fehler fehl | Verschachtelte `sh -c '...'`-Anführungszeichen in einer Zeile | Install-Skript erst in eine Datei laden, dann sauber mit `su - vscode -c "sh /tmp/install-omz.sh --unattended"` ausführen |
| 5 | `sops`-Binary war hart auf `linux.amd64` gepinnt | Lief auf Apple Silicon nur zufällig per QEMU-Emulation von Docker Desktop mit, nicht nativ | Arch dynamisch über `dpkg --print-architecture` auflösen (`linux.arm64` / `linux.amd64`) |
| 6 | `docker.sock`-Zugriff im Container: `permission denied` | Der gemountete Host-Socket gehört (je nach Docker-Setup) einer Gruppe/GID, die im Container nicht existiert oder nicht der `docker`-Gruppe entspricht (auf Docker Desktop für Mac/Windows z.B. GID 0/root statt einer eigenen `docker`-Gruppe) | `docker-init.sh`: Root-Entrypoint gleicht beim Container-Start die GID der `docker`-Gruppe an den tatsächlich gemounteten Socket an (bzw. tritt der GID-Inhaber-Gruppe bei, falls die GID schon vergeben ist), wechselt danach per `gosu` zu `vscode` |
**Konsequenz für die Zukunft:** Vor jeder größeren Änderung an `.devcontainer/` (oder
mindestens vierteljährlich) einmal real bauen und laufen lassen:
```bash
docker build -f .devcontainer/Dockerfile -t ess-gitops-devcontainer-test .devcontainer
docker run --rm \
-v ~/.kube:/home/vscode/.kube \
-v ~/.age:/home/vscode/.age \
-v /var/run/docker.sock:/var/run/docker.sock \
ess-gitops-devcontainer-test bash -c '
kubectl version --client && helm version --short && flux --version && \
sops --version && age --version && docker version --format "{{.Server.Version}}" && \
id vscode
'
```
Wenn `docker version` hier den echten Server, nicht nur die Client-Version zeigt, und
`id vscode` die passende Docker-Gruppe/GID auflistet, funktioniert der Socket-Zugriff
tatsächlich — nicht nur der Build.
## 📚 Weitere Ressourcen
- [Dev Containers Docs](https://containers.dev)
+8 -1
View File
@@ -50,8 +50,15 @@
"HACK",
"NOTE",
"XXX",
"DONE"
"DONE",
"[ ]",
"[x]"
],
"todo-tree.regex.regex": "(//|#|<!--|;|/\\*|^|^\\s*(-|\\d+.))\\s*($TAGS)",
"todo-tree.highlights.customHighlight": {
"[ ]": { "background": "#ff000080", "icon": "issue-opened" },
"[x]": { "background": "#00ff0080", "icon": "check" }
},
"todo-tree.tree.showScanModeButton": true,
"todo-tree.filtering.includeGlobs": [
"**/docs/TASKS.md",
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Runs as root at container start (before any `docker exec -u vscode` from VS Code).
# The docker.sock's GID is only known once the host socket is actually bind-mounted,
# so it can't be baked in at image build time - it must be reconciled here, at runtime.
set -e
if [ -S /var/run/docker.sock ]; then
SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
CURRENT_GID=$(getent group docker | cut -d: -f3)
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "$CURRENT_GID" ]; then
EXISTING_GROUP=$(getent group "$SOCK_GID" | cut -d: -f1)
if [ -n "$EXISTING_GROUP" ]; then
# GID is already taken by another group (e.g. GID 0/root - Docker Desktop for
# Mac/Windows owns the socket this way inside its VM), so join that group
# instead of trying to reassign it to 'docker'.
usermod -aG "$EXISTING_GROUP" vscode
else
groupmod -g "$SOCK_GID" docker
fi
fi
fi
exec gosu vscode "$@"
+1 -1
View File
@@ -26,7 +26,7 @@ echo ""
echo "📚 Useful commands:"
echo " - kubectl get pods -n matrix (check pod status)"
echo " - flux get helmreleases -A (check helm releases)"
echo " - sops apps/production/custom-configs/mas-secrets.sops.yaml (edit secrets)"
echo " - sops apps/production/custom-configs/mas-secret.yaml (edit secrets)"
echo ""
echo "🔗 For kubeconfig setup:"
echo " - Copy your ~/.kube/config to access the cluster"
-50
View File
@@ -1,50 +0,0 @@
name: Auto-Deploy on Push
on:
push:
branches:
- main
paths:
- 'apps/**'
- 'clusters/**'
- '.gitea/workflows/**'
jobs:
verify-and-notify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check YAML Syntax
run: |
echo "🔍 Validating YAML files..."
find apps clusters -name "*.yaml" -type f | while read file; do
if ! grep -q "^apiVersion:" "$file"; then
echo "⚠️ Warning: $file may not be a valid K8s manifest"
fi
done
echo "✅ YAML validation passed"
- name: Check for SOPS Encryption
run: |
echo "🔐 Checking SOPS status..."
for file in $(git diff --name-only origin/main...HEAD -- '**/secret*.yaml' '**/credentials*.yaml'); do
if grep -q "ENC\[" "$file"; then
echo "✅ $file is encrypted"
else
echo "⚠️ WARNING: $file may not be encrypted!"
fi
done
- name: Create Deployment Notification
run: |
echo "📤 Flux will reconcile changes within 1 minute"
echo "🔗 Monitor in Gitea: Projects → Releases (check tags)"
- name: List Changed Files
run: |
echo "📋 Files changed in this push:"
git diff --name-only origin/main...HEAD
-32
View File
@@ -1,32 +0,0 @@
name: Create Release on Milestone Tag
on:
push:
tags:
- 'm*-*-complete'
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Extract Milestone Info
id: milestone
run: |
TAG="${GITHUB_REF#refs/tags/}"
TITLE=$(git tag -l "$TAG" -n1 | awk '{print substr($0, index($0, $2))}')
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "title=$TITLE" >> $GITHUB_OUTPUT
echo "🏷️ Milestone: $TAG"
echo "📝 Title: $TITLE"
- name: Create Release
run: |
echo "📦 Creating release for milestone: ${{ steps.milestone.outputs.tag }}"
echo "${{ steps.milestone.outputs.title }}" > /tmp/release-notes.txt
echo "Created: $(date)" >> /tmp/release-notes.txt
cat /tmp/release-notes.txt
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
.claude/
+26
View File
@@ -0,0 +1,26 @@
# Leichter Verifikations-Job, portiert aus .gitea/workflows/deploy-on-push.yml
# (Gitea-CI-Rueckbau, siehe Backlogs CFGMON-11). Deployt nichts - Flux reconciled
# weiterhin selbststaendig aus dem Gitea-Mirror. Repo-Topologie: git.lab ist
# kanonisch, rohana/Gitea ist Push-Mirror und Flux-Quelle.
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)."
+341
View File
@@ -0,0 +1,341 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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. Issues/wiki/releases stay on Gitea. The same rule
applies to ThreadNet-Web, threadnet-call, thread-net-git and threadnet-operating; only
the `Backlogs` repo (and this repo's wiki) are still direct-to-Gitea.
## Repository Structure
```
gitops/
├── 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 Gitea 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 [Gitea issues](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues))
- **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)
+62 -13
View File
@@ -4,18 +4,23 @@ Dieses Repository enthält die Infrastruktur-as-Code (IaC) für den Matrix-Homes
## 📑 Inhaltsverzeichnis
1. [Voraussetzungen & Lokale Tools](https://www.google.com/search?q=%231-voraussetzungen--lokale-tools)
2. [Architektur & Logik des Stacks](https://www.google.com/search?q=%232-architektur--logik-des-stacks)
3. [Aufbau des Repositories](https://www.google.com/search?q=%233-aufbau-des-repositories)
4. [Das Deployment (Aktueller Stand)](https://www.google.com/search?q=%234-das-deployment-aktueller-stand)
5. [Nützliche Befehle](https://www.google.com/search?q=%235-n%C3%BCtzliche-befehle)
6. [Troubleshooting & Known Issues](https://www.google.com/search?q=%236-troubleshooting--known-issues)
1. [Voraussetzungen & Lokale Tools](#1-voraussetzungen--lokale-tools)
2. [Architektur & Logik des Stacks](#2-architektur--logik-des-stacks)
3. [Aufbau des Repositories](#3-aufbau-des-repositories)
4. [Das Deployment (Aktueller Stand)](#4-das-deployment-aktueller-stand)
5. [Nützliche Befehle](#5-nützliche-befehle)
6. [Troubleshooting & Known Issues](#6-troubleshooting--known-issues)
7. [Weitere Ressourcen](#7-weitere-ressourcen)
-----
## 1\. Voraussetzungen & Lokale Tools
Um mit diesem Stack zu interagieren (Konfigurationen anzupassen, Secrets zu verschlüsseln, Fehler zu suchen), müssen folgende Tools lokal installiert sein:
**Empfohlen: `.devcontainer/` nutzen** ("Reopen in Container" in VS Code, oder `docker build`
+ `docker run` manuell, siehe [`.devcontainer/README.md`](.devcontainer/README.md)) - bringt
alle unten genannten Tools bereits fertig eingerichtet mit, ohne sie lokal zu installieren.
Alternativ, um mit diesem Stack zu interagieren (Konfigurationen anzupassen, Secrets zu verschlüsseln, Fehler zu suchen), müssen folgende Tools lokal installiert sein:
### 🛠️ Benötigte CLI-Tools
@@ -70,9 +75,10 @@ Das Setup basiert auf einer modernen, modularen GitOps-Architektur:
* **K3s**: Die leichtgewichtige Kubernetes-Distribution, die als Fundament dient.
* **FluxCD**: Der GitOps-Controller. Er überwacht dieses Git-Repository. Ändert sich hier eine Datei, wendet Flux die Änderung automatisch im Cluster an.
* **SOPS**: Erlaubt es, Passwörter (z.B. SMTP) verschlüsselt in Git zu speichern. Flux entschlüsselt diese "on the fly" im Cluster.
* **SOPS + age**: Erlaubt es, Secrets verschlüsselt in Git zu speichern. Flux entschlüsselt diese "on the fly" im Cluster. Mehrere Secrets nutzen zusätzlich einen zweiten, eng gescopten age-Key für automatisierte Rotation (siehe coturn TURN-Secret unten).
* **Traefik**: Der Ingress-Controller (Standard bei K3s). Er leitet Traffic von Port 80/443 an die richtigen internen Pods weiter.
* **Cert-Manager**: Spricht mit Let's Encrypt und stellt automatisch gültige TLS-Zertifikate für alle Ingress-Routen aus.
* **NetworkPolicies**: Default-Deny Ingress für die `matrix`- und `authentik`-Namespaces, mit expliziten Allow-Regeln pro Komponente (`apps/production/networkpolicy.yaml`, `apps/authentik/networkpolicy.yaml`).
### Matrix Stack (ESS Community v26.4.0)
@@ -80,10 +86,26 @@ Die Suite ist ein "Umbrella Chart", das aus mehreren Microservices besteht:
* **Synapse (`matrix.`):** Das eigentliche Backend (Homeserver) für die Chat-Nachrichten.
* **Matrix Authentication Service (MAS) (`account.`):** Der OIDC-basierte Login-Server. Zwingend erforderlich für moderne Matrix-Clients.
* **Element Web (`domain.tld`):** Der Web-Client für die Endnutzer.
* **Matrix RTC (`mrtc.`):** Die SFU (Selective Forwarding Unit) für Audio-/Video-Calls.
* **Element Web (`domain.tld`):** Eigener Fork (`sorb/threadnet-web`) des Web-Clients für die Endnutzer - Custom Themes, Element Desktop Setup-Seiten, Element-Call-Anpassungen.
* **Matrix RTC (`mrtc.`):** Die SFU (Selective Forwarding Unit) für Audio-/Video-Calls, mit eigenem Element-Call-Fork (`sorb/threadnet-call`) für höhere Video-Defaults (bis 1440p/60fps).
* **coturn:** TURN/STUN-Server für WebRTC hinter NAT (`hostNetwork: true`, außerhalb der NetworkPolicy-Kontrolle, stattdessen über die Hetzner Cloud Firewall abgesichert). Shared Secret wird monatlich automatisiert rotiert.
* **PostgreSQL:** Die relationale Datenbank für Synapse und MAS.
### Identity & Observability
* **Authentik** (`auth.`, `account.`): OIDC-Identity-Provider für Matrix-Enrollment, Passwort-Recovery und optionales 2FA/Passkey. Flows/Provider/Application deklarativ als Authentik-Blueprints erfasst (`apps/authentik/authentik-blueprints.yaml`), nicht nur in der UI geklickt.
* **Monitoring**: Grafana Alloy sammelt Metriken/Logs, Remote-Write zu einem externen Prometheus/Loki-Stack.
* **Backups**: Nächtliche, verschlüsselte & deduplizierte Borg-Backups (Postgres-Dumps + Synapse-`media_store`) zu einer Hetzner Storage Box, getrennt nach Namespace, mit eigenen Repos/Passphrasen.
### Moderation & Content Scanning
* **Draupnir**: Moderationsbot (Community-Nachfolger von Mjolnir) für Ban-Listen/Policy-Rooms.
* **ClamAV**: Zwei Bausteine für unterschiedliche Räume - ein eigenes Synapse-Modul (`clamav_spam_checker.py`) scannt Uploads in unverschlüsselten Räumen; ein zusätzlicher, eigenständiger `clamav-http-scanner`-Dienst wird vom gepatchten Element-Web-Client (`sorb/threadnet-web`) sowohl beim Senden als auch beim Empfangen aufgerufen und deckt damit auch verschlüsselte Räume/DMs ab. Details: `docs/deployment-guides/06-moderation-content-scanning.md`.
### Host-Level (nicht-GitOps) Änderungen
* `host-config/` ist bewusst der einzige Teil dieses Repos, den Flux **nicht** verwaltet - Skripte/systemd-Units, die direkt auf dem nackten Hetzner-Host laufen (z.B. `unattended-upgrades`-Vorab-Benachrichtigungen), für Dinge, die strukturell außerhalb der Reichweite von Flux liegen. Deployment erfolgt manuell per SSH, instanzspezifische Werte liegen in einer Config-Datei auf dem Host, nicht im versionierten Skript. Details: `docs/deployment-guides/07-host-maintenance-notifications.md`.
-----
## 3\. Aufbau des Repositories
@@ -106,9 +128,23 @@ 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, Wiki und
Releases bleiben auf Gitea.
**Abhängigkeits-Logik:** Flux installiert erst `infra-apps` (damit Namespaces und Repositories existieren) und danach `production-apps` (das eigentliche ESS-Chart).
-----
@@ -156,7 +192,10 @@ spec:
name: ess-synapse-custom
valuesKey: values.yaml
- kind: Secret
name: ess-mas-custom-secrets
name: ess-mas-values-secret
valuesKey: values.yaml
- kind: Secret
name: synapse-turn-secret
valuesKey: values.yaml
values:
serverName: axion1337.chat
@@ -216,7 +255,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
```
-----
@@ -245,3 +284,13 @@ sops apps/production/custom-configs/mas-secrets.sops.yaml
* **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.
-----
## 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://rohana.axion1337.de/sorb/axion1337.chat-gitops/wiki)**: Ausführliche Historie, Incident-Notizen, Setup-Guides pro Komponente.
* **`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: {}
+283
View File
@@ -0,0 +1,283 @@
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.
- model: authentik_stages_prompt.promptstage
state: present
identifiers:
name: matrix-invitation-prompt
attrs:
validation_policies: []
# 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).
- model: authentik_flows.flow
state: present
identifiers:
slug: matrix-recovery
id: matrix_recovery_flow
attrs:
designation: recovery
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
order: 10
attrs:
stage: !Find [authentik_stages_identification.identificationstage, [name, default-recovery-identification]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
order: 20
attrs:
stage: !Find [authentik_stages_email.emailstage, [name, default-recovery-email]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
order: 30
attrs:
stage: !Find [authentik_stages_prompt.promptstage, [name, "Change your password"]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
order: 40
attrs:
stage: !Find [authentik_stages_user_write.userwritestage, [name, default-recovery-user-write]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
order: 100
attrs:
stage: !Find [authentik_stages_user_login.userloginstage, [name, default-recovery-user-login]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf matrix_recovery_flow
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
- model: authentik_stages_identification.identificationstage
state: present
identifiers:
name: default-authentication-identification
attrs:
recovery_flow: !KeyOf matrix_recovery_flow
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]]
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]]
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
+5 -4
View File
@@ -7,10 +7,10 @@ stringData:
secret_key: ENC[AES256_GCM,data:yIyQapbFtFM11LynFtkV3ffExhaDfN9QHeFbI1T0xkIhgsV+9sjg3qwMVmeBlAe7xZl8gsAM4kDj2Q6O91OdDg==,iv:+Cl8vOcxG9/mgRheaCO0bLWyCJXN+f1F2DD3oeHbPFY=,tag:711ytyKf6/tmXomBLoffGA==,type:str]
pg-password: ENC[AES256_GCM,data:3w8R9mRjMXMJDLjrC8QYaXFHsCU3yYZs2PcaFQNp3Z4=,iv:G/aXgoGz3vBOzZ5K3Y+DDJsqer4F5gvcMmtkzRx93CU=,tag:dXPs1pY/APvnMlxdvB1EkA==,type:str]
smtp-password: ENC[AES256_GCM,data:JpMgaQFPkBzOg5WjvpmhM0kPwvZkH+4tQjT17RJHjG14WjmWtfG9Bg==,iv:zjQRLIlrxKv5hbd4JZowNUEiibiCUMf79acZY0+dYAc=,tag:ORPafTPyOQJvVvHWQGmqhA==,type:str]
mas-oidc-client-secret: ENC[AES256_GCM,data:0yx55FroLSxlnuYgfNwczu3PnbPm1kW74JtiU9oFevVqeQDZc385wU6x5X5TN7owXDO7QaOfGTTMvqIpbwQb6Q5Vt1VMToR+0f44oJcktYoTiDFU9Sy6lR/y6nlvBCNqeJg7vIyVpkIqxwqty15EekyqMpkIMp1fT6Pxmek0SO0=,iv:Ey06ljnqbVARDLVt2sLe8R776VEWpTlzI/+Nka5NocA=,tag:I+GNLHz4V8TFa2ijzK5y2Q==,type:str]
sops:
age:
- recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRekJuZythYzliTFJ3RlhS
R2p6TG9NeFdabFlPRWtpNHJMYVVxTWZEcmlRClk0WUorSzdxNlcyWHYwWFBTMnlq
@@ -18,7 +18,8 @@ sops:
QXVrY1NTeHZkeTlPRWNlVThzWno3T0kKC0KBoLT64GNqb8Ri9u69G7nqb1KftwwP
/24aVHrPxKi9d4ij9n3bvCYDF4rhtfexhrE4n7CfuKn2DcSiuTniuw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-29T21:43:59Z"
mac: ENC[AES256_GCM,data:Y+dJppkaVZ5NOhlvwbbsF5+vDFqGUI1Ps8IcE4J7FIW4HIdMVf6RKM0EInvPUW1LaBlmelCitcE30w0As7ysNRhLY8yUDaKUvuU6mRejlNUIF8wAHzhciL2jTvAQsArHjybJatEig28+wM9VcY8JEa/d/CmuiB9Nq4WbIV+JXlA=,iv:UQj2rIVLNPjtYp3d/jRyNfJyyyUsZ3+NDCgpI4aztzc=,tag:cwiCzG/A+rfRFfLjXVt82w==,type:str]
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-07-28T15:54:53Z"
mac: ENC[AES256_GCM,data:P6IF+jukwzldK92nHl6s4h6sS4ldXLwpyLpwv2tpI3vFWgTLEnGCnowi2k5lmWUlITEVmLLC0HvsBuduTiGI2sIHHt+r3RdqkV88HGn6oYDVq5a+Ax7ESfqti/4B7ClQCSxl/tU6hBUFe812DiBXJgA03UJQZn8uHY/dP/RgRpc=,iv:V8sqhbJcKglkKsQmJBdgoxDaCYJ3Wt/qRa18jEviH60=,tag:EiNotrYAKIzKndgjU/kTFQ==,type:str]
version: 3.12.2
+18
View File
@@ -40,6 +40,15 @@ spec:
global:
security:
allowInsecureImages: true
# Read by the matrix-oidc-provider blueprint via !Env, so the OAuth2
# Provider's client_secret can be captured as code without ever
# inlining the live credential into a plain (non-SOPS) ConfigMap.
env:
- name: AUTHENTIK_MAS_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: authentik-credentials
key: mas-oidc-client-secret
authentik:
log_level: info
@@ -52,6 +61,10 @@ spec:
use_tls: true
from: "Authentik <gamemaster@axion1337.chat>"
blueprints:
configMaps:
- authentik-blueprints
server:
ingress:
enabled: false
@@ -85,3 +98,8 @@ spec:
memory: 256Mi
limits:
memory: 512Mi
# Chart's own generated policy allows ANY pod in ANY namespace on 5432
# (see issue #37) - disabled in favor of our own scoped policy in
# apps/authentik/networkpolicy.yaml.
networkPolicy:
enabled: false
+5
View File
@@ -4,6 +4,11 @@ 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
+96
View File
@@ -0,0 +1,96 @@
# 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
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -102,7 +102,7 @@ data:
// Scrape Synapse metrics
prometheus.scrape "synapse" {
targets = [{
__address__ = "matrix-stack-synapse-main.matrix.svc.cluster.local:9000",
__address__ = "matrix-stack-synapse-main.matrix.svc.cluster.local:9001",
}]
forward_to = [prometheus.remote_write.selendis.receiver]
scrape_interval = "30s"
+8
View File
@@ -35,6 +35,14 @@ spec:
services:
- name: element-web-docs
port: 80
# Client-seitiger ClamAV-Scan-Dienst (Issue #19-Erweiterung: Scanning auch für
# verschlüsselte Räume, direkt vom Browser aus aufgerufen)
- match: Host(`axion1337.chat`) && PathPrefix(`/_scan`)
kind: Rule
priority: 50
services:
- name: clamav-http-scanner
port: 8090
# Niedrigere Priorität: alles andere -> Element Web
- match: Host(`axion1337.chat`)
kind: Rule
@@ -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
+19 -10
View File
@@ -4,19 +4,28 @@ metadata:
name: coturn-secret
namespace: matrix
stringData:
TURN_SECRET: ENC[AES256_GCM,data:SILIqMB+fmAMFITAL7lG1hOgICec6BJf1mOcK0gdmnCHWYqRuJv7jgjfGylG25xzQKi+zE7Qual9PnkZG2KiOA==,iv:+GZqLGusE4Q0x2jEEtFxj06rryyQmQhXdkTy4eE8ZHw=,tag:OpSZkinPTAi1ZKWyo8OX3A==,type:str]
TURN_SECRET: ENC[AES256_GCM,data:Cbu5SoxQp0L9WFZFbEiyK8j0IJuSgoJE9OpRKZVXyF+PcTM+ewD+3TI8xq+g0C34XDWeaJdxaj3B4yyKsqQLIA==,iv:lW8lD3D75Z0b0EfBj5myUrv4GWMvRTBELjhCVm+QJjc=,tag:nC5L/PcYPoLJP1whSSfSTA==,type:str]
sops:
age:
- recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyRk1mK3NWc1l4T0JCOFpF
S0RuQ3ViZmo3QTNVL2JvZ0hzMy91R2l0TEhzCk01a1VGdk1sdVg4aWswTzRibXI4
ZlJtNFF5MjBONEZOaWVpeU5taHl2bkEKLS0tIGxpUHY3NUFLWFBaWm1QSlZiVFkx
MEJleHFnd3oyT3VPL2dsYkpMUlRkOWMKcKUIgsQ/ff49pGGXMnYwJmwqPVC7woAR
IEzvhcNX97xx746SnrxZe5t2YadsYMkYIl0nvqBPJhSlvqMNafpQbQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQMncxOEhRZC9jcHpjT3dW
Q1RNaW5pc00zVnJhWHRnZHd3TGhURWNZU2dJCk85bXJkbTEyd1ZybjhDT05pL2c3
ZU9EUSt1eDlSQWJyVGtsak1oS3FSR2sKLS0tIEFUdXVHL0V1ZW5VMVVBZEJaYUIw
U1BrYlJyQVZkZFhBdmdwbDMyK3lTQkkKEaSy1o+IICf2uaT6olapRJa/duXxjOBg
OqRS9axnJ71XxEnHjLTsCbkI5b+8Fux08qKaH9sMsJrWOiSHDdTXXQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-29T21:06:21Z"
mac: ENC[AES256_GCM,data:UhyR5m1HYWrZHwNLW5sg2PxbpaydWbP5cekghGlzSpQg7CYEcvZw3tJ/qB8zA19xZSM7tdSHOXdD+QytRq6qW59M1unqMaumA43B6JxQg1C1NdXAW0mkSc2WiNchvgpVii9P/TVlzSSIRwC3YGCQUsfa3SSfNzI4Z6fMuBnhYLE=,iv:4HYxbrYSRJLe1KcQ6q8bpee8/Lx1m3pPmisb/L2Mu64=,tag:l7n3u+Pg6533OzwtNUZvNw==,type:str]
recipient: age14l0hwfqylwpemz5y2ghh2yxk0phszlnj3qlejhue0fw0kz3tmfgqdsjzdh
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwQk82eGdWRmdRNFJQR0xF
SWhNRUtNWThZNFM0aXU3V0N4UkhtTy9NTFdnCi9pa3dsVXRja1dTL1pZTnoxQ0JT
UUIxekZnVGUvdFgyblFiS0JLMjU3L2cKLS0tIE1IRTJ0M3kvMFZPWVVDYjJlVkk0
eGJQOTVUc1NsNE5GdmJtODlmdHp3c2cKHTP6YRMTdYE/iBuSZs/Tjt4TwKCxHEIu
f3jTblKIqWwRHKCgOIkC16QDbpMBlNLH3JknJEdIjkB2HIrXrw1MNA==
-----END AGE ENCRYPTED FILE-----
recipient: age1x4jjwc8nuttwr8us924pvdc6dll5npkc6c8f4zf2hx5d2qu75dtqx0fm0d
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-08-01T02:00:01Z"
mac: ENC[AES256_GCM,data:1Tx2/4O/fcv9BPYLXainmsILC2HbBJtJkgVnbkCe9oVSKca/hVf6zAa8Pd6n/tvHHAuU1Ghm2mglk/IxbNKgCDHH+xaEeK5sfhAK0Ot2ffIMLK5chBNMH0DpvTjqP8ttMidgPJ4XYzizB850gq7pSRrZBJCC2oG66F+WPnfKkxQ=,iv:NIDfNbIyWSgvjpXtQFVjnZFjtKx8sV3a45ssiebkHf0=,tag:hNDmHPTu4RyNMvjyfJRCmA==,type:str]
version: 3.12.2
+17 -5
View File
@@ -65,6 +65,17 @@ metadata:
namespace: matrix
spec:
replicas: 1
# hostNetwork pods bind directly to the node's ports (3478/5349) - on this single-node
# cluster, RollingUpdate's default "bring up the new pod before removing the old one"
# can never schedule (port conflict). Recreate kills the old pod first.
# Note: switching to Recreate on an existing Deployment that already had the
# RollingUpdate defaults recorded required a one-time manual
# `kubectl patch --type=merge -p '{"spec":{"strategy":{"rollingUpdate":null,"type":"Recreate"}}}'`
# (2026-07-28) - a YAML `rollingUpdate: null` in this file is dropped before reaching the
# API server (client-side omits null keys) rather than sent as an explicit field deletion,
# so it can't clear an already-set field on its own.
strategy:
type: Recreate
selector:
matchLabels:
app: coturn
@@ -74,6 +85,10 @@ spec:
app: coturn
annotations:
prometheus.io/scrape: "false"
# Bumped on every TURN_SECRET rotation (Issue #38) to force a new pod, since
# Kubernetes doesn't restart running pods when a referenced Secret's content
# changes and the initContainer that reads it only runs once at pod start.
rotated-at: "2026-08-01T02:00:01Z"
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
@@ -130,11 +145,8 @@ spec:
cpu: 100m
memory: 128Mi
livenessProbe:
exec:
command:
- /bin/sh
- -c
- "netstat -uln | grep 3478 || exit 1"
tcpSocket:
port: 3478
initialDelaySeconds: 30
periodSeconds: 10
volumes:
@@ -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: |
@@ -58,15 +74,13 @@ data:
room_list_publication_rules:
- user_id: "*"
action: allow
turn:
config: |
turn_uris:
- "turn:turn.axion1337.chat?transport=udp"
- "turn:turn.axion1337.chat?transport=tcp"
- "turns:turn.axion1337.chat?transport=tcp"
turn_shared_secret: "cab3c8408363515d9b4cdc3384a1f76ca17a973242fdfdc72b67ac4d86158527"
turn_user_lifetime: 86400000
turn_allow_guests: false
oembed:
config: |
oembed_enabled: true
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
+17 -2
View File
@@ -4,7 +4,11 @@ metadata:
name: matrix-stack
namespace: matrix
spec:
interval: 5m
# Shortened from 5m to match production-apps Kustomization's 1m interval - narrows the
# window between coturn (Kustomization-only, no Helm indirection) and synapse-main
# (behind this HelmRelease) picking up a rotated TURN secret after Issue #38's
# automated-rotation PR gets merged. Self-heals either way, just faster now.
interval: 1m
chart:
spec:
chart: matrix-stack
@@ -25,6 +29,9 @@ 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
@@ -59,6 +66,14 @@ 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:
@@ -66,7 +81,7 @@ spec:
image:
registry: rohana.axion1337.de
repository: sorb/threadnet-web
tag: v0.1.0
tag: v0.3.0-clientscan
ingress:
host: axion1337.chat
@@ -193,6 +193,7 @@ data:
<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">
@@ -203,6 +204,117 @@ data:
</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
@@ -21,6 +21,7 @@ spec:
- |
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/
+35 -2
View File
@@ -1,8 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Patch: Fügt einen Checksum der element-values.yaml zur HelmRelease hinzu
# Damit wird Flux die HelmRelease neu-synced wenn sich die ConfigMap ändert
# Patch: Fügt Checksums der element-values.yaml und des turn_shared_secret zur
# HelmRelease hinzu. Damit wird Flux die HelmRelease neu-synced (und synapse-main neu
# gestartet), wenn sich die jeweilige ConfigMap/Secret ändert - siehe Issue #38's
# Rotations-Mechanismus, der turn-secret-checksum bei jeder Rotation bumpt.
patches:
- target:
kind: HelmRelease
@@ -12,6 +14,9 @@ patches:
- op: add
path: /metadata/annotations/element-config-checksum
value: "401f8a87d0ef5d91d2e5032d4aede42c"
- op: add
path: /metadata/annotations/turn-secret-checksum
value: "05aad8b742fb02c42f4c1a5629ae31e1"
resources:
- matrix-postgres-auth.yaml
@@ -26,7 +31,35 @@ resources:
# TURN Server für WebRTC
- coturn-secret.yaml
- coturn.yaml
- synapse-turn-secret.yaml
# HelmRelease (muss ganz unten stehen, damit die ConfigMaps vorher da sind!)
- element-server-suite.yaml
# Custom Apex Ingress für Element Web + Well-Known auf axion1337.chat
- apex-ingress.yaml # Custom Apex Ingress für Element Web + Well-Known auf axion1337.chat
- networkpolicy.yaml
# Backup zur Hetzner Storage Box (Issues #6 + #15)
- synapse-backup-secret.yaml
- synapse-backup.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
# 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:
- name: synapse-clamav-module
namespace: matrix
files:
- clamav_spam_checker.py
options:
disableNameSuffixHash: true
+361
View File
@@ -0,0 +1,361 @@
# 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
@@ -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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+225 -255
View File
@@ -1,7 +1,7 @@
# aXion1337.Chat Task List & Meilensteine
**Last Updated**: 2026-05-15
**Statusübersicht**: [✅ 9 Abgeschlossen] [🔄 0 In Progress] [📋 11+ Pending] [🔒 10 Security]
**Last Updated**: 2026-07-28
**Statusübersicht**: [✅ 13 Abgeschlossen] [🔄 0 In Progress] [📋 8+ Pending] [🔒 10 Security]
---
@@ -9,9 +9,9 @@
| Kategorie | Count | Status | Details |
|-----------|-------|--------|---------|
| **Completed** | 9 | ✅ Done | K3S, Flux, ESS, Themes, Desktop, Monitoring, TURN, Authentik, Firewall, SSH |
| **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** | 11+ | 📋 Pending | DB Backups, E2E Test, Element Call Fork, PostgreSQL Migration, NetworkPolicies |
| **Backlog** | 8+ | 📋 Pending | DB Backups, PostgreSQL Migration, MAS-Template-Link, VP9-Retry |
| **Security Tasks** | 5 | 🔒 Pending | auditd, Kernel hardening, CrowdSec, Falco, WAF |
### Priority Distribution
@@ -25,6 +25,63 @@
---
## 🗓️ 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**
@@ -58,33 +115,53 @@
- **Status**: NEXT
### 🟠 **NEXT 12 WEEKS HIGH**
1. **Authentik End-to-End Test**
- Test: Login flow Element → MAS → Authentik → Matrix User
- Test: Password reset
- Create: Test invite links
- Est. Time: 2 hours
1. **Authentik End-to-End Test** — erledigt als Teil von Issue #7 (Enrollment/Recovery/2FA,
2026-07-27), mit echten Test-Usern verifiziert. **Status**: COMPLETE
2. **Element Call Fork**
- Fork: element-hq/element-call
- Feature: Video/audio constraints parameters
- Integration: Synapse well-known config
- Est. Time: 23 days
2. **Element Call Fork** — erledigt, Closes Issue #8 (2026-07-28), siehe
`docs/deployment-guides/04-element-customization.md` Kapitel 4. **Status**: COMPLETE
3. **External PostgreSQL Migration**
3. **External PostgreSQL Migration** → [Issue #9](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/9)
- Decision: CloudNativePG vs. Hetzner Postgres
- Setup: HA + Replication
- Migration: Move data from ESS embedded Postgres
- Testing: Verify all services work
- Est. Time: 12 days
4. **NetworkPolicies Deployment**
- Create: Default-Deny for `matrix` namespace
- Create: Allow rules (Synapse↔Postgres, MAS↔Postgres, Ingress→Web, etc.)
- Test: Ensure no service breakage
- Est. Time: 1 day
4. **NetworkPolicies Deployment** — erledigt, Closes Issue #10 (2026-07-28), Default-Deny
für `matrix`+`authentik` Namespaces. **Status**: COMPLETE
---
## 🗓️ Session-Zusammenfassung 2026-07-30 bis 2026-08-01 (CI/CD-Umzug ins Lab)
1. **CI/CD komplett ins Homelab-GitLab migriert** (`git.lab`, nur im Lab auflösbar) —
nach vier realen Gitea-Actions/act-Bugs und einem verifizierten Webpack-OOM auf dem
3,7-GiB-CFGMON-Runner. ThreadNet-Web-Pipeline vollautomatisch grün (Web-Build,
Image-Push nach rohana, Desktop-Linux deb/tar.gz). Schlankes Trigger-Schema: Pushes
prüfen nur bei relevanten Pfaden, Artefakte entstehen bei `v*`-Tags.
(ThreadNet-Web#2 ✅ geschlossen)
2. **Repo-Topologie neu**: git.lab ist kanonisch für gitops, ThreadNet-Web,
threadnet-call, thread-net-git, threadnet-operating — Push-Mirrors nach Gitea,
das Flux-Quelle/Registry/Issues behält. Details: README §3 / CLAUDE.md.
3. **Windows-Build-Strecke** (ThreadNet-Web#5): eigene Windows-VM auf Overmind aus
selbst gebautem, reviewtem dockur/windows-Stand (Vendor-Repo + Runbook), On-Demand
per CI-Jobs; Gast provisioniert, Runner registriert — erster voller Build-Durchlauf
stand zum Session-Ende noch aus (Runner-Dienst-Hänger nach VM-Neustart).
4. **Lab-Container-Registry** aktiviert (`registry.git.lab`, OVERMIND-01 ✅): lab-interne
Build-Images (windows-vm, desktop-build) bleiben im Lab, rohana behält nur, was
Prod konsumiert.
5. **Gitea-CI-Rückbau** (CFGMON-11, weitgehend): Verifikations-Job hierher portiert
(`.gitlab-ci.yml`), `.gitea/workflows/` entfernt, Actions-Toggles deaktiviert,
Runner-Entfernung als Commit vorbereitet — drei manuelle Restschritte beim Nutzer.
6. **threadnet-call-CI** (threadnet-call#1): build_embedded grün, npm-Registry bleibt
evidenzbasiert auf rohana (pnpm-Lockfile-Pin), manueller Publish-Job wartet auf
`GITEA_NPM_TOKEN`-Variable.
7. **Alerting vorbereitet** (Issue #32): Alertmanager + 6 Alert-Regeln +
Matrix-Receiver in `threadnet-operating`, gelintet, Deploy steht aus (CFGMON).
8. Nebenbefunde: GitLab-Puma lief mit ~17 Workern (Unicorn-Fossil in der Config,
Fix beim Nutzer), Windows-Gast-Provisionierung als idempotenter CI-Job.
## ✅ Abgeschlossene Aufgaben (Chronologisch)
### Phase 1: Basis-Setup
@@ -206,289 +283,181 @@
**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)
### Authentik Completion
- [ ] **Finish Authentik Stage 2 MAS Integration**
- Prerequisites: Authentik OIDC Provider vollständig konfiguriert
- Task: Update `mas-secret.yaml`, enable password login disable
- Commit: `enable-authentik-oidc-integration-in-mas`
- Est. Effort: 30 min (manual + scripted)
- [ ] **Test End-to-End Login Flow**
- Element Web login → MAS → Authentik → Matrix User Creation
- Create test users via Authentik
- Verify password reset flow
- Commit: (implicit in Stage 2)
- Est. Effort: 20 min
- [ ] **Create Invite Links für neue User**
- Authentik Admin UI → Invitations → Create
- Set expiry dates (7d) + use limits
- Document procedure
- Est. Effort: 15 min
**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
- [ ] **Element Call Fork für Custom Constraints**
- Repository: Fork `element-hq/element-call`
- Feature: Video/Audio constraints parameter im config
- Include: Bandwidth limiting, resolution limits, frame rate control
- Integration mit Synapse well-known
- Est. Effort: 23 days (fork + feature + test)
- Priority: **HIGH** (user feature)
- [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/Dedicated PostgreSQL Deployment**
- Option 1: CloudNativePG Operator (open-source, auf K3S)
- Option 2: Managed Hetzner Postgres
- Separate aus ESS matrix-stack embedded Postgres
- HA + Replication
- Est. Effort: 12 days
- Priority: **HIGH** (reliability)
- [ ] **External/Dedizierte PostgreSQL-Migration** → [Issue #9](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/9)
- [ ] **Database Backup Strategy**
- Daily automated backups (PgBackRest oder velero)
- Off-site backup storage (S3 / Hetzner Storage Box)
- Monthly verified restores (test restore → verify data integrity)
- Backup + restore documentation
- Est. Effort: 23 days
- Priority: **CRITICAL** (disaster recovery)
- [ ] **Synapse Media PVC Backups**
- Separate backup pipeline für `/data/media_store` PVC
- Reason: Media oft >100GB, sollte nicht im DB-Backup sein
- Velero + Restic für block-level backup
- Est. Effort: 1 day
- Priority: **HIGH** (data preservation)
(Database Backup Strategy und Synapse Media PVC Backups waren hier ursprünglich als eigene
Punkte gelistet - beide erledigt und geschlossen, siehe [#6](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/6)
und [#15](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/15), Details in
[[00-TASKS]] Wiki bzw. Release v0.16.0.)
### Network Security
- [ ] **NetworkPolicies K8s-Layer Segmentation**
- Default-Deny Ingress für `matrix` namespace
- Allow rules:
- Ingress → MAS:443
- Ingress → ElementWeb:443
- MAS ↔ Synapse:8008
- Synapse ↔ Postgres:5432
- Authentik → Postgres:5432
- Authentik → Loki:3100 (monitoring)
- Egress: Matrix-specific (federation, etc.)
- Est. Effort: 1 day
- Priority: **MEDIUM** (compliance, least-privilege)
- [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)**
- Apply to `matrix` & `authentik` namespaces
- Enforce: non-root, no privileged, read-only root fs
- Test: Ensure no chart breakage
- Est. Effort: 1 day
- Priority: **MEDIUM** (hardening)
- [ ] **Pod Security Admission (Restricted)** → [Issue #16](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/16)
### Federation & Access Control
- [ ] **Federation-Allowlist oder Closed Federation**
- Decision: Which servers to federate with?
- If allowlist: explicit `federation_domain_whitelist`
- If closed: `allow_public_rooms_without_join_rules: false`
- Synapse config in `synapse-values.yaml`
- Est. Effort: 4 hours
- Priority: **MEDIUM** (security policy)
- [ ] **Federation-Allowlist oder Closed Federation** → [Issue #17](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/17)
### Moderation & Anti-Abuse
- [ ] **Mjolnir/Draupnir Bot Deployment**
- Open-source moderation bot für Matrix
- Reason: Invitation-based, aber Federation kann Spam bringen
- Auto-ban known bad servers/users
- Spam-detection rules
- HelmChart oder custom Deployment
- Est. Effort: 12 days
- Priority: **MEDIUM** (ops safety)
- [ ] **Content Scanner for Media**
- matrix-content-scanner + ClamAV antivirus
- Scan uploaded media for malware
- Block suspicious files
- Est. Effort: 12 days
- Priority: **LOWMEDIUM** (optional but good practice)
- [x] **Draupnir Moderationsbot** → [Issue #18](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/18) (2026-07-29, deployed + live getestet)
- [x] **Content Scanner für Media** → [Issue #19](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/19) (2026-07-29, Synapse-Modul + ClamAV für unverschlüsselte Räume, plus client-seitiger Scan im ThreadNet-Web-Fork für verschlüsselte Räume/DMs - live getestet, beide Richtungen; siehe Deployment-Guide)
- [ ] **Grafana-Dashboard für ClamAV-Erkennungen** → [Issue #43](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/43)
- [ ] **ThreadNet-Web: Electron-Desktop-Build automatisieren** → [Issue #44](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/44) (kein CI-Runner, Fork-Änderungen landen aktuell nicht automatisch im Desktop-Client)
### Secrets Management
- [ ] **External-Secrets Operator oder SOPS für Flux**
- Current: SOPS with age encryption
- Consideration: External-Secrets for cloud-native (AWS Secrets Manager, Hetzner Vault, etc.)
- OR: Improve SOPS rotation strategy
- Decision needed: Keep SOPS or upgrade?
- Est. Effort: 23 days (if switching)
- Priority: **LOW** (current SOPS setup working)
- [ ] **External-Secrets Operator vs. SOPS-Setup** → [Issue #20](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/20)
### Image & Dependency Management
- [ ] **Renovate / Dependabot Setup**
- Auto-update Helm Chart versions
- Auto-update Container Image Tags
- Monitor for security patches
- Est. Effort: 4 hours
- Priority: **MEDIUM** (maintenance)
- [ ] **Trivy Image Scanning**
- Scan images in Flux HelmReleases for CVEs
- Block deployment if critical CVE found
- CI/CD hook in git workflow
- Est. Effort: 8 hours
- Priority: **LOWMEDIUM** (security posture)
- [ ] **Monitor ESS & Element Security Advisories**
- Subscribe to `element-hq` security mailing list
- Monitor `#matrix-community` security channels
- Auto-alerts on new CVEs/patches
- Est. Effort: Ongoing (low maintenance)
- Priority: **MEDIUM** (security awareness)
- [ ] **Renovate/Dependabot Setup** → [Issue #21](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/21)
- [ ] **Trivy Image Scanning** → [Issue #31](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/31)
- [ ] **Security Advisory Monitoring (ESS/Element)** → [Issue #22](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/22)
### Container Security
- [ ] **Disable automountServiceAccountToken Everywhere**
- Audit all Deployments/StatefulSets
- Disable for: Synapse, ElementWeb, MAS, Postgres, Authentik (where not needed)
- Add `automountServiceAccountToken: false` to spec.template.spec
- Test: Ensure no breakage
- Est. Effort: 4 hours
- Priority: **MEDIUM** (least-privilege)
- [ ] **automountServiceAccountToken deaktivieren wo nicht benötigt** → [Issue #23](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/23)
---
## 🔒 Security Hardening (Host & Cluster Level)
### Host OS Layer (Ubuntu/Debian)
- [ ] **Hetzner Cloud Firewall**
- Default-Deny inbound
- Allow: 80/443 (HTTP/HTTPS)
- Allow: 22 (SSH) from your IP only (or via WireGuard/Tailscale)
- Status: ✅ Can be done in Hetzner UI
- Est. Effort: 30 min
- Priority: **CRITICAL** (immediate, zero config cost)
- [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.**
- [ ] **SSH Hardening**
- Disable password auth (key-only)
- Disable root login
- PermitRootLogin: no
- PasswordAuthentication: no
- MaxAuthTries: 3
- Optional: Change SSH port (cosmetic, reduces log noise)
- Optional: SSH hinter WireGuard/Tailscale (eliminates fail2ban für SSH)
- Est. Effort: 2 hours
- Priority: **HIGH** (immediate)
- [ ] **unattended-upgrades**
- Enable automatic security updates
- Configure: APT::Periodic::Update-Package-Lists "1";
- Configure: APT::Periodic::Unattended-Upgrade "1";
- Configure: APT::Periodic::AutocleanInterval "7";
- Est. Effort: 30 min
- Priority: **HIGH** (set & forget)
- [ ] **K3S API Security**
- Current: K3S API listening on :6443 on all interfaces (default)
- Hardening:
- Option 1: Firewall restrict :6443 to localhost only
- Option 2: K3S --bind-address + --advertise-address to WireGuard IP
- Option 3: kubectl access only via jumphost/bastion
- Est. Effort: 2 hours
- Priority: **HIGH** (API is high-value target)
- [ ] **auditd for File Integrity & Syscall Audit**
- Monitor: /etc, ~/.kube, /var/lib/rancher/k3s
- Audit rules für sensitive file changes
- Low overhead, good signal/noise ratio
- Output to syslog / centralized logging
- Est. Effort: 2 hours
- Priority: **MEDIUM** (forensics + compliance)
- [ ] **Kernel Hardening (sysctl)**
- Apply hardening recommendations from Lynis
- Key settings:
- kernel.kptr_restrict=2 (hide kernel pointers)
- kernel.dmesg_restrict=1 (restrict dmesg)
- net.ipv4.tcp_syncookies=1 (SYN flood protection)
- net.ipv4.conf.all.rp_filter=1 (reverse path filtering)
- net.ipv4.conf.all.send_redirects=0
- net.ipv6.conf.all.disable_ipv6=0 (or =1 if no IPv6 needed)
- Persist via /etc/sysctl.d/99-hardening.conf
- Est. Effort: 2 hours
- Priority: **MEDIUM** (defense in depth)
- [ ] **Lynis Security Baseline**
- Run `lynis audit system`
- Review recommendations
- Implement high-priority findings
- Aim for score >80
- Re-run quarterly
- Est. Effort: 4 hours (initial) + 1 hour quarterly
- Priority: **MEDIUM** (baseline verification)
- [x] **unattended-upgrades** (2026-07-30) war bereits aktiv (`APT::Periodic::*` seit
längerem gesetzt, Origins-Pattern deckt Debian+Debian-Security ab), nur nie dokumentiert.
Ergänzt: Pre-Update-Benachrichtigung per Mail+Matrix, fest vor dem 06:00-07:00-Update-Fenster.
Siehe [07-host-maintenance-notifications.md](deployment-guides/07-host-maintenance-notifications.md).
Closes [Issue #24](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/24)
- [ ] **K3s API Security Hardening** → [Issue #25](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/25)
- [ ] **auditd (File Integrity & Syscall Audit)** → [Issue #26](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/26)
- [ ] **Kernel Hardening (sysctl)** → [Issue #27](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/27)
- [ ] **Lynis Security Baseline** → [Issue #28](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/28)
### Cluster Layer (K3S / Kubernetes)
- [ ] **CrowdSec Integration**
- Install CrowdSec agent on host
- Connect to CrowdSec Hub (commercial platform, free tier available)
- Feed auth.log, syslog → CrowdSec for attack detection
- Auto-block IPs via local firewall or Hetzner Firewall API
- Est. Effort: 4 hours
- Priority: **MEDIUM** (proactive threat response)
- [ ] **Falco Runtime Monitoring**
- Install Falco DaemonSet in K3S
- Monitor: Shell spawning in containers, suspicious syscalls, privilege escalation
- Output to Loki / syslog
- Alert on anomalies
- Est. Effort: 1 day
- Priority: **MEDIUM** (runtime detection)
- [ ] **CrowdSec Integration** → [Issue #29](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/29)
- [ ] **Falco Runtime Monitoring** → [Issue #30](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/30)
---
## 🎯 Meilensteine (Milestones)
## 🎯 Versionierung
| Meilenstein | Beschreibung | Status | ETA |
|------------|-------------|--------|-----|
| **M1: Basis-Setup** | K3S + Flux + ESS deployed | ✅ Done | - |
| **M2: Core Matrix** | Themes, Scripts, Policies | ✅ Done | - |
| **M3: WebRTC & Monitoring** | TURN + Alloy/Prometheus/Loki | ✅ Done | - |
| **M4: Identity Provider** | Authentik Stage 1+2 (pending Stage 2) | 🔄 In Progress | ~12 days |
| **M5: Production-Ready** | DB Backups, NetworkPolicies, Security Hardening | 📋 Backlog | ~23 weeks |
| **M6: Advanced Features** | Element Call Fork, Content Scanner, Mjolnir | 📋 Backlog | ~4+ weeks |
| **M7: Enterprise-Ready** | Full compliance (DSGVO), HA setup, Disaster Recovery | 🎯 Future | ~8+ weeks |
Seit 2026-07-28 SemVer statt der alten m1-m7-Meilensteine - siehe
[Releases](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/releases) für die volle,
detaillierte Historie (aktuell bis v0.17.0) und [[00-TASKS]] im Wiki für die Konvention
(MINOR = neue Fähigkeit, PATCH = Fix/Tuning/Doku).
---
## 📊 Prioritäts-Kategorien
### 🔴 CRITICAL (do immediately)
- Hetzner Cloud Firewall setup
- Database backup strategy
- SSH hardening
Alle Punkte hier sind als Gitea-Issues nachgehalten (Nummern siehe oben/Backlog-Verweis) - diese
Kategorisierung ist nur eine grobe Einordnung, keine zweite Tracking-Quelle.
### 🟠 HIGH (do within 12 weeks)
- Authentik Stage 2 completion
- External PostgreSQL migration
- NetworkPolicies
- Element Call fork
### 🟠 HIGH
- External PostgreSQL migration (#9)
### 🟡 MEDIUM (do within 1 month)
- CrowdSec + Falco
- Mjolnir bot
- Renovate/Trivy
- PSA restricted mode
- Kernel hardening
- CrowdSec + Falco (#29, #30)
- Renovate/Trivy (#31, #32)
- K3s API Hardening, auditd, Kernel Hardening, Lynis (#25-#28)
### 🟢 LOW (nice-to-have, do if time allows)
- Content scanner (ClamAV)
- External-Secrets upgrade
- SSH port relocation
- Advanced federation rules
---
## 📝 Notes & Decision Points
### Authentik Stage 2 Blocker
**Waiting for**: User to manually configure Authentik OIDC Provider in Authentik Admin UI.
- Once done, provide Client ID + Secret
- Then: Commit Stage 2 MAS config
### Database: CloudNativePG vs. Hetzner Postgres
- **CloudNativePG**: Open-source, runs on K3S, full control
- **Hetzner Postgres**: Managed, backups included, less ops overhead
@@ -509,12 +478,13 @@
- `docs/deployment-guides/README.md` Overview
- `docs/deployment-guides/01-turn-server-setup.md` TURN
- `docs/deployment-guides/02-authentik-identity-provider.md` Authentik (Stage 1 + Stage 2 plan)
- `docs/deployment-guides/02-authentik-identity-provider.md` Authentik (Stage 1+2 + Enrollment/Recovery/2FA)
- `docs/deployment-guides/03-monitoring-integration.md` Monitoring
- `docs/deployment-guides/04-element-customization.md` Themes, Desktop
- `docs/deployment-guides/04-element-customization.md` Themes, Desktop, Element Call Fork
- `docs/deployment-guides/05-room-policies.md` Policies
- `docs/deployment-guides/06-moderation-content-scanning.md` Draupnir, ClamAV Content Scanning
- `docs/deployment-guides/07-host-maintenance-notifications.md` Host-Wartungsbenachrichtigungen
---
**Last Updated**: 2026-05-14
**Next Review**: 2026-05-21
**Last Updated**: 2026-07-30
@@ -1,7 +1,6 @@
# Authentik als Identity Provider für Matrix
**Status**: ✅ Stage 1 Deployed (Authentik läuft)
**Pending**: Stage 2 (MAS Integration)
**Status**: ✅ Deployed (Stage 1 + Stage 2 + Enrollment/Recovery/2FA, Closes Issue #7)
**Domain**: `auth.axion1337.chat`
## Überblick
@@ -41,5 +40,22 @@ Authentik = OIDC Provider für MAS → Zentrales Login + Einladungs-basierte Reg
Authentik Admin → Flows & Stages → Invitations → Create
## Enrollment/Recovery/2FA Fix (2026-07-27, Issue #7)
Der `matrix-invitation`-Flow hatte nur 2 von 5 nötigen Stages (kein Write/Password/Login) -
Nutzer wurden nie in Synapse angelegt. Behoben und als Authentik Blueprint
(`apps/authentik/authentik-blueprints.yaml`) deklarativ ins Repo übernommen: vollständiger
`matrix-invitation`-Flow (Invite → Prompt → Write → Password → Login → Redirect), leerer
`matrix-recovery`-Flow ergänzt, `Brand.default_application` gesetzt. 2FA/Passkey-Selbst-
Einrichtung optional (`not_configured_action=skip`), 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.
@@ -44,6 +44,61 @@
**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 |
@@ -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
+12 -1
View File
@@ -9,10 +9,12 @@ Die Implementierungen wurden in dieser Reihenfolge durchgeführt. Für neue Setu
| # | Titel | Datei | Status | Zieldomäne |
|---|-------|-------|--------|-----------|
| 1 | TURN Server für WebRTC Video-Calls | `01-turn-server-setup.md` | ✅ Deployed | `turn.axion1337.chat` |
| 2 | Authentik als Identity Provider | `02-authentik-identity-provider.md` | ✅ Stage 1 Deployed | `auth.axion1337.chat` |
| 2 | Authentik als Identity Provider | `02-authentik-identity-provider.md` | ✅ Deployed | `auth.axion1337.chat` |
| 3 | Monitoring mit Alloy/Prometheus/Loki | `03-monitoring-integration.md` | ✅ Deployed | lokal (10.0.0.3) |
| 4 | Element Web Anpassung & Desktop-Apps | `04-element-customization.md` | ✅ Deployed | `axion1337.chat` |
| 5 | Room Policies (Retention, Publication, Auto-Join) | `05-room-policies.md` | ✅ Deployed | Matrix Synapse |
| 6 | Moderationsbot (Draupnir) & Content Scanning | `06-moderation-content-scanning.md` | ✅ Deployed | Matrix Synapse |
| 7 | Host-Wartungsbenachrichtigungen (unattended-upgrades) | `07-host-maintenance-notifications.md` | ✅ Deployed | Host-Ebene (kein K8s) |
---
@@ -85,6 +87,15 @@ 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).
---
## 🛠️ Wartung & Troubleshooting
+55
View File
@@ -76,3 +76,58 @@ flux get helmreleases -n matrix --watch
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.
---
## 🔁 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.
View File
+3 -3
View File
@@ -85,8 +85,8 @@ Dieser Ordner enthält detaillierte Troubleshooting- und Reparaturanleitungen f
| Problem | Nutzer | Guide | Status |
|---------|--------|-------|--------|
| Nur Standard Enrollment funktioniert | akadmin ✅ | - | Resolved |
| User nur in Authentik, nicht in Synapse | Boje | `DIAGNOSTIK-AUTHENTIK-FLOW.md` | In Progress |
| Einladungslink-Fehler: "kein ausstehender benutzer" | Klaus | `AUTHENTIK-CREATE-INVITATION-FLOW.md` | In Progress |
| User nur in Authentik, nicht in Synapse | Boje | `DIAGNOSTIK-AUTHENTIK-FLOW.md` | **Resolved (2026-07-27)** — identischer Root Cause wie bei Klaus (fehlende Write/Password/Login-Stages im `matrix-invitation`-Flow), behoben durch denselben Issue-#7-Fix. Nicht erneut mit Boje selbst nachgetestet, aber mit anderen Test-Usern (`clark`, `lucky`) end-to-end verifiziert - der zugrundeliegende Flow ist jetzt für jeden Nutzer korrekt. |
| Einladungslink-Fehler: "kein ausstehender benutzer" | Klaus | `AUTHENTIK-CREATE-INVITATION-FLOW.md` | **Fixed (2026-07-27)**`matrix-invitation` Flow hatte nur Invite+Prompt Stage-Bindings, beide auf `order=0`. Write/Password/Login-Stages fehlten komplett. Live gefixt + als Blueprint (`apps/authentik/authentik-blueprints.yaml`) reproduzierbar gemacht. |
| OIDC-Integration unklar | General | `AUTHENTIK-FIX-TEMPLATE.md` | Reference |
---
@@ -108,5 +108,5 @@ Dieser Ordner enthält detaillierte Troubleshooting- und Reparaturanleitungen f
---
**Zuletzt aktualisiert**: 2026-05-18
**Zuletzt aktualisiert**: 2026-07-30
**Verfasser**: Claude Code + Thore
@@ -0,0 +1,28 @@
# Example for /etc/maintenance-notify/config (host-level, NOT deployed via
# GitOps/Flux - copy manually to the target host and adjust for your own
# instance). Sourced as a plain bash file by maintenance-notify.sh.
#
# The values below are axion1337.chat's own, real configuration - shown as a
# concrete worked example. Replace every value for your own homeserver/room.
# Your homeserver's base URL (matrix client-server API). This is often a
# dedicated subdomain, NOT your apex domain - check your own
# .well-known/matrix/client delegation (`m.homeserver.base_url`) to be sure.
# For axion1337.chat specifically it's matrix.axion1337.chat, not the apex.
MATRIX_HOMESERVER="https://matrix.axion1337.chat"
# The room the notification gets posted into.
MATRIX_ROOM_ID="!lmZaajvVboTPxQxXzv:axion1337.chat"
# The thread to reply into (matrix.to link's event id after the room id).
# IMPORTANT: Matrix event IDs start with "$" - this value MUST be
# single-quoted, otherwise bash will try to expand "$T3MQZgf..." as a
# variable and silently truncate it to an empty string.
MATRIX_THREAD_EVENT_ID='$T3MQZgf-maQwfshCKlCn0bo4DGHn4sZS-8eI9u2V6ZI'
# Sending identity - must match the "user"/"from" in /etc/msmtprc.
MAIL_FROM="wartung@axion1337.chat"
# Where the pre-update heads-up actually lands (your everyday inbox, not
# necessarily the sending mailbox above).
MAIL_TO="your-address@example.com"
@@ -0,0 +1,8 @@
[Unit]
Description=Pre-update maintenance notification (Mail + Matrix)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/maintenance-notify.sh
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# Pre-update maintenance notification. Runs as a systemd oneshot service, well
# before apt-daily-upgrade.timer's own window, and tells you (Mail + Matrix)
# which packages are about to be auto-upgraded - so a post-update problem can
# immediately be traced back to "the update ran, that's probably it".
#
# Generic by design: no instance-specific values are hardcoded here. All of
# them live in /etc/maintenance-notify/config - see config.example in this
# same directory, and docs/deployment-guides/07-host-maintenance-notifications.md
# for the full setup guide.
set -euo pipefail
CONFIG_FILE="/etc/maintenance-notify/config"
MATRIX_TOKEN_FILE="/etc/maintenance-notify/matrix-token"
if [ ! -f "$CONFIG_FILE" ]; then
echo "Missing $CONFIG_FILE - see docs/deployment-guides/07-host-maintenance-notifications.md" >&2
exit 1
fi
# shellcheck source=/dev/null
. "$CONFIG_FILE"
: "${MATRIX_HOMESERVER:?MATRIX_HOMESERVER not set in $CONFIG_FILE}"
: "${MATRIX_ROOM_ID:?MATRIX_ROOM_ID not set in $CONFIG_FILE}"
: "${MATRIX_THREAD_EVENT_ID:?MATRIX_THREAD_EVENT_ID not set in $CONFIG_FILE}"
: "${MAIL_FROM:?MAIL_FROM not set in $CONFIG_FILE}"
: "${MAIL_TO:?MAIL_TO not set in $CONFIG_FILE}"
apt-get update -qq
DRYRUN_OUTPUT="$(unattended-upgrade --dry-run -v 2>&1)"
# Exact log strings taken from /usr/bin/unattended-upgrade itself (verified
# live on the target host), not guessed - this is the one message emitted
# when there is nothing to do, and the one line emitted with the package
# list otherwise. They're mutually exclusive.
if echo "$DRYRUN_OUTPUT" | grep -q "No packages found that can be upgraded unattended"; then
echo "No pending upgrades - nothing to notify."
exit 0
fi
PENDING_PKGS="$(echo "$DRYRUN_OUTPUT" | sed -n 's/^.*Packages that will be upgraded: //p' | tail -1)"
if [ -z "$PENDING_PKGS" ]; then
echo "No pending upgrade packages parsed - nothing to notify."
exit 0
fi
HOST_LABEL="$(hostname -f 2>/dev/null || hostname)"
NOW="$(date '+%Y-%m-%d %H:%M %Z')"
BODY="Host: ${HOST_LABEL}
Zeitpunkt: ${NOW}
Im naechsten apt-daily-upgrade.timer-Fenster werden folgende Pakete automatisch aktualisiert:
${PENDING_PKGS}
Automatische Vorab-Benachrichtigung, keine Aktion erforderlich."
send_mail() {
if ! command -v msmtp >/dev/null 2>&1; then
echo "msmtp not installed, skipping mail notification" >&2
return 1
fi
{
echo "From: ${MAIL_FROM}"
echo "To: ${MAIL_TO}"
echo "Subject: [${HOST_LABEL}] Anstehendes Update"
echo
echo "$BODY"
} | msmtp -a maintenance-notify -- "${MAIL_TO}"
}
send_matrix() {
if [ ! -f "$MATRIX_TOKEN_FILE" ]; then
echo "Missing $MATRIX_TOKEN_FILE, skipping Matrix notification" >&2
return 1
fi
local token txn_id encoded_room payload
token="$(cat "$MATRIX_TOKEN_FILE")"
txn_id="$(uuidgen)"
encoded_room="$(jq -rn --arg s "$MATRIX_ROOM_ID" '$s|@uri')"
payload="$(jq -n --arg body "$BODY" --arg event_id "$MATRIX_THREAD_EVENT_ID" \
'{msgtype: "m.text", body: $body, "m.relates_to": {rel_type: "m.thread", event_id: $event_id}}')"
curl -sS -f -X PUT \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
--data "$payload" \
"${MATRIX_HOMESERVER}/_matrix/client/v3/rooms/${encoded_room}/send/m.room.message/${txn_id}" \
> /dev/null
}
mail_ok=0
matrix_ok=0
send_mail && mail_ok=1
send_matrix && matrix_ok=1
if [ "$mail_ok" -eq 0 ] && [ "$matrix_ok" -eq 0 ]; then
echo "Both mail and Matrix notification failed" >&2
exit 1
fi
@@ -0,0 +1,15 @@
[Unit]
Description=Daily pre-update maintenance notification, fires before apt-daily-upgrade.timer's window
[Timer]
# Fixed, unrandomized time - must fire safely before the earliest possible
# start of apt-daily-upgrade.timer. Default apt-daily-upgrade.timer ships as
# OnCalendar=*-*-* 6:00 with RandomizedDelaySec=60m (actual run: 06:00-07:00).
# If your apt-daily-upgrade.timer differs (check with
# `systemctl cat apt-daily-upgrade.timer`), adjust the time below to keep a
# comfortable lead.
OnCalendar=*-*-* 05:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,24 @@
# Template for /etc/msmtprc (host-level, NOT deployed via GitOps/Flux - copy
# manually to the target host and fill in the placeholders yourself).
#
# Copy to /etc/msmtprc, replace the __PLACEHOLDER__ values below with your own
# transactional-mail provider's SMTP details, then:
# chmod 600 /etc/msmtprc
# The password itself is NOT stored here - it's read at send-time from
# /etc/maintenance-notify/mail-password (chmod 600, plain text, one line, no
# trailing newline needed either way).
account maintenance-notify
host __SMTP_HOST__
port __SMTP_PORT__
tls on
# Port 465 = implicit TLS (tls_starttls off, as below). If your provider uses
# port 587/STARTTLS instead, flip this to "tls_starttls on".
tls_starttls off
auth on
user __MAIL_FROM__
passwordeval "cat /etc/maintenance-notify/mail-password"
from __MAIL_FROM__
logfile /var/log/msmtp.log
account default : maintenance-notify