cluster

Infrastructure files for Nordgedanken and Midnightthoughts.
git clone git://archive.git.mtrnord.blog/MTRNord/cluster.git
Log | Files | Refs | README

commit a3b686a3720fa5ede0dd42d536f5e02d2cd35673
parent 632a6d251fc784e194e1abc93837da1c10a5d5e8
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Sat,  4 Apr 2026 00:56:11 +0200

improve matrix backup tool

Signed-off-by: MTRNord <MTRNord@users.noreply.github.com>

Diffstat:
Mapps/dns-a/gatus/config.yaml | 50+++++++++++++++++++++++++-------------------------
Aapps/talos_cluster/image-builder/build-matrix-backup-cronjob.yaml | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapps/talos_cluster/image-builder/kustomization.yaml | 1+
Mapps/talos_cluster/matrix-backup/backup-cronjob.yaml | 53++++++++---------------------------------------------
Dapps/talos_cluster/matrix-backup/backup-script-configmap.yaml | 888-------------------------------------------------------------------------------
Aapps/talos_cluster/matrix-backup/backup-tool/Dockerfile | 28++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/go.mod | 12++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/main.go | 1129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapps/talos_cluster/matrix-backup/kustomization.yaml | 1-
9 files changed, 1339 insertions(+), 959 deletions(-)

diff --git a/apps/dns-a/gatus/config.yaml b/apps/dns-a/gatus/config.yaml @@ -117,7 +117,7 @@ endpoints: interval: 1m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - "[RESPONSE_TIME] < 5000" - name: Grafana @@ -126,7 +126,7 @@ endpoints: interval: 2m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: ntfy group: Infrastructure @@ -135,7 +135,7 @@ endpoints: conditions: - "[STATUS] == 200" - "[BODY].healthy == true" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Flux Webhook group: Infrastructure @@ -143,7 +143,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == any(200, 404)" # endpoint exists, auth required - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" ############################################################################## # SOCIAL & COMMUNICATION @@ -155,7 +155,7 @@ endpoints: interval: 1m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - "[RESPONSE_TIME] < 3000" - name: Matrix (continuwuity) @@ -164,7 +164,7 @@ endpoints: interval: 1m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - "has([BODY].versions) == true" - name: Matrix Well-Known @@ -173,7 +173,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Matrix Client Well-Known group: Social @@ -181,7 +181,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Draupnir group: Social @@ -189,7 +189,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == any(200, 404)" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" ############################################################################## # MAIL @@ -229,7 +229,7 @@ endpoints: interval: 15m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: WKD (nordgedanken.dev) group: Mail @@ -237,7 +237,7 @@ endpoints: interval: 15m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" ############################################################################## # PERSONAL SERVICES @@ -249,7 +249,7 @@ endpoints: interval: 1m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Immich group: Personal @@ -258,7 +258,7 @@ endpoints: conditions: - "[STATUS] == 200" - "[BODY].res == pong" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: FreshRSS group: Personal @@ -266,7 +266,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Bookwyrm group: Personal @@ -274,7 +274,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: ActualBudget group: Personal @@ -282,7 +282,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Peertube group: Personal @@ -290,7 +290,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Collabora group: Personal @@ -298,7 +298,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" ############################################################################## # PRODUCTIVITY @@ -310,7 +310,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Plane group: Productivity @@ -318,7 +318,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Weblate group: Productivity @@ -326,7 +326,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" ############################################################################## # PUBLISHING & DEV @@ -338,7 +338,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: cgit group: Publishing @@ -346,7 +346,7 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Connectivity Tester group: Publishing @@ -354,7 +354,7 @@ endpoints: interval: 2m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" - name: Connectivity Tester API group: Publishing @@ -362,4 +362,4 @@ endpoints: interval: 5m conditions: - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" + - "[CERTIFICATE_EXPIRATION] > 24h" diff --git a/apps/talos_cluster/image-builder/build-matrix-backup-cronjob.yaml b/apps/talos_cluster/image-builder/build-matrix-backup-cronjob.yaml @@ -0,0 +1,136 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: build-matrix-backup + namespace: image-builder +spec: + schedule: "0 4 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + ttlSecondsAfterFinished: 86400 + template: + spec: + serviceAccountName: image-builder-cronjob + restartPolicy: Never + initContainers: + - name: setup-buildctl + image: moby/buildkit:v0.29.0 + command: [cp, /usr/bin/buildctl, /tools/buildctl] + volumeMounts: + - name: tools + mountPath: /tools + + - name: setup-cosign + image: alpine:3 + command: + - /bin/sh + - -c + - | + wget -qO /tools/cosign https://github.com/sigstore/cosign/releases/download/v3.0.5/cosign-linux-arm64 \ + && chmod +x /tools/cosign + volumeMounts: + - name: tools + mountPath: /tools + + - name: setup-crane + image: alpine:3 + command: + - /bin/sh + - -c + - | + wget -qO /tmp/crane.tar.gz https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_arm64.tar.gz \ + && tar -xzf /tmp/crane.tar.gz -C /tools crane \ + && chmod +x /tools/crane + volumeMounts: + - name: tools + mountPath: /tools + + - name: clone-repo + image: alpine/git:latest + command: + - git + - clone + - --depth=1 + - https://github.com/MTRNord/cluster.git + - /workspace + volumeMounts: + - name: workspace + mountPath: /workspace + + containers: + - name: build + image: alpine:3 + command: + - /bin/sh + - -c + - | + set -eu + apk add --no-cache git >/dev/null + export PATH="/tools:$PATH" + + mkdir -p /root/.docker + cp /registry-secret/dockerconfig.json /root/.docker/config.json + + TAG_TS="$(date -u +%Y%m%d-%H%M%S)" + TAG_SHA="sha-$(git -C /workspace rev-parse --short HEAD)" + IMAGE="registry.midnightthoughts.space/mtrnord/cluster/matrix-backup" + + echo "==> Building ${IMAGE}:${TAG_TS}" + + buildctl \ + --addr tcp://buildkitd.image-builder.svc.cluster.local:1234 \ + build \ + --frontend dockerfile.v0 \ + --opt platform=linux/amd64,linux/arm64 \ + --local context=/workspace/apps/talos_cluster/matrix-backup/backup-tool \ + --local dockerfile=/workspace/apps/talos_cluster/matrix-backup/backup-tool \ + --output "type=image,name=${IMAGE}:${TAG_TS},push=true,oci-mediatypes=true" \ + --metadata-file /tmp/build-meta.json + + DIGEST="$(crane digest ${IMAGE}:${TAG_TS})" + echo "==> Digest: ${DIGEST}" + + crane cp "${IMAGE}:${TAG_TS}" "${IMAGE}:main" + crane cp "${IMAGE}:${TAG_TS}" "${IMAGE}:${TAG_SHA}" + + COSIGN_PASSWORD="$(cat /cosign-secret/cosign.password)" \ + cosign sign --yes \ + --key /cosign-secret/cosign.key \ + --new-bundle-format=false \ + --use-signing-config=false \ + --registry-referrers-mode=oci-1-1 \ + "${IMAGE}@${DIGEST}" + + echo "==> Done: ${IMAGE}:${TAG_TS}" + env: + - name: COSIGN_OCI_EXPERIMENTAL + value: "1" + - name: COSIGN_EXPERIMENTAL + value: "1" + volumeMounts: + - name: tools + mountPath: /tools + - name: workspace + mountPath: /workspace + readOnly: true + - name: registry-secret + mountPath: /registry-secret + readOnly: true + - name: cosign-secret + mountPath: /cosign-secret + readOnly: true + + volumes: + - name: tools + emptyDir: {} + - name: workspace + emptyDir: {} + - name: registry-secret + secret: + secretName: image-builder-registry-secret + - name: cosign-secret + secret: + secretName: image-builder-cosign-secret diff --git a/apps/talos_cluster/image-builder/kustomization.yaml b/apps/talos_cluster/image-builder/kustomization.yaml @@ -11,3 +11,4 @@ resources: - build-blog-cronjob.yaml - build-continuwuity-cronjob.yaml - build-bookwyrm-cronjob.yaml + - build-matrix-backup-cronjob.yaml diff --git a/apps/talos_cluster/matrix-backup/backup-cronjob.yaml b/apps/talos_cluster/matrix-backup/backup-cronjob.yaml @@ -18,40 +18,14 @@ spec: restartPolicy: OnFailure securityContext: runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 seccompProfile: type: RuntimeDefault - initContainers: - - name: install-deps - image: python:3.12-slim - command: - - /bin/sh - - -c - - pip install --quiet --no-cache-dir --target /deps 'matrix-nio[e2e]' boto3 cryptography - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: false - capabilities: - drop: - - ALL - resources: - requests: - cpu: 200m - memory: 256Mi - limits: - memory: 512Mi - volumeMounts: - - name: deps - mountPath: /deps containers: - name: backup - image: python:3.12-slim - command: - - /bin/sh - - -c - - PYTHONPATH=/deps python /scripts/backup.py + image: registry.midnightthoughts.space/mtrnord/cluster/matrix-backup:main securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -61,7 +35,7 @@ spec: resources: requests: cpu: 100m - memory: 256Mi + memory: 128Mi limits: memory: 512Mi env: @@ -107,23 +81,12 @@ spec: name: matrix-backup-secret key: key_export_passphrase volumeMounts: - - name: scripts - mountPath: /scripts - readOnly: true - - name: crypto-store - mountPath: /data/crypto - - name: deps - mountPath: /deps - readOnly: true + - name: data + mountPath: /data - name: tmp mountPath: /tmp volumes: - - name: scripts - configMap: - name: matrix-backup-script - - name: crypto-store - emptyDir: {} - - name: deps + - name: data emptyDir: {} - name: tmp emptyDir: {} diff --git a/apps/talos_cluster/matrix-backup/backup-script-configmap.yaml b/apps/talos_cluster/matrix-backup/backup-script-configmap.yaml @@ -1,888 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: matrix-backup-script - namespace: matrix-backup -data: - backup.py: | - #!/usr/bin/env python3 - """ - Matrix backup script. - - Auth lifecycle: - - First run: login with password → creates a dedicated "matrix-backup" device. - session.json (access_token + device_id) is saved into the crypto store. - - Subsequent runs: restore_login from session.json. - - The password in the secret is ONLY used on first run. - - SSSS bootstrap (first run only): - - Decrypts the megolm key backup private key from Secure Secret Storage using - the provided recovery key. - - Fetches all backed-up room keys from the server. - - Decrypts them with the backup private key (olm PK) and imports them into - the local crypto store so the bot can decrypt E2EE messages. - - Per-run backup: - - Crypto store round-tripped via S3 tarball (download at start, upload at end). - - Room list with IDs, aliases, server hints, names, types. - - E2EE exported key file (encrypted with KEY_EXPORT_PASSPHRASE). - - Incremental message history as JSONL. - - Images sent by *:mtrnord.blog users (downloaded and stored to S3). - - Display-name and avatar changes for *:mtrnord.blog users. - """ - import asyncio - import base64 - import hashlib - import hmac as hmac_lib - import io - import json - import mimetypes - import os - import re - import struct - import sys - import tarfile - import tempfile - from collections import Counter - from datetime import datetime, timezone - - import boto3 - from botocore.exceptions import ClientError - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from cryptography.hazmat.primitives.kdf.hkdf import HKDF - from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - from nio import ( - AsyncClient, - AsyncClientConfig, - DownloadError, - LoginError, - MessageDirection, - RoomMessagesError, - ) - - # Environment - HOMESERVER = os.environ["HOMESERVER"] - S3_ENDPOINT = os.environ["S3_ENDPOINT"] - S3_BUCKET = os.environ["S3_BUCKET"] - S3_ACCESS_KEY = os.environ["S3_ACCESS_KEY"] - S3_SECRET_KEY = os.environ["S3_SECRET_KEY"] - KEY_EXPORT_PASSPHRASE = os.environ["KEY_EXPORT_PASSPHRASE"] - MTRNORD_PASSWORD = os.environ["MTRNORD_PASSWORD"] - MTRNORD_SSSS_KEY = os.environ["MTRNORD_SSSS_KEY"] - LEXI_PASSWORD = os.environ["LEXI_PASSWORD"] - LEXI_SSSS_KEY = os.environ["LEXI_SSSS_KEY"] - - OUR_HOMESERVER_SUFFIX = ":mtrnord.blog" - DATE_STR = datetime.now(timezone.utc).strftime("%Y%m%d") - - ACCOUNTS = [ - { - "user_id": "@mtrnord:mtrnord.blog", - "password": MTRNORD_PASSWORD, - "ssss_key": MTRNORD_SSSS_KEY, - "store": "/data/crypto/mtrnord", - "prefix": "mtrnord", - }, - { - "user_id": "@lexi:mtrnord.blog", - "password": LEXI_PASSWORD, - "ssss_key": LEXI_SSSS_KEY, - "store": "/data/crypto/lexi", - "prefix": "lexi", - }, - ] - - # S3 helpers - s3 = boto3.client( - "s3", - endpoint_url=f"https://{S3_ENDPOINT}", - aws_access_key_id=S3_ACCESS_KEY, - aws_secret_access_key=S3_SECRET_KEY, - region_name="hel1", - ) - - - def s3_get_json(key): - try: - obj = s3.get_object(Bucket=S3_BUCKET, Key=key) - return json.loads(obj["Body"].read()) - except ClientError as e: - if e.response["Error"]["Code"] in ("NoSuchKey", "404"): - return None - raise - - - def s3_get_bytes(key): - try: - obj = s3.get_object(Bucket=S3_BUCKET, Key=key) - return obj["Body"].read() - except ClientError as e: - if e.response["Error"]["Code"] in ("NoSuchKey", "404"): - return None - raise - - - def s3_put(key, data: bytes, content_type: str = "application/octet-stream"): - s3.put_object(Bucket=S3_BUCKET, Key=key, Body=data, ContentType=content_type) - - - def s3_exists(key): - try: - s3.head_object(Bucket=S3_BUCKET, Key=key) - return True - except ClientError as e: - if e.response["Error"]["Code"] in ("404", "NoSuchKey"): - return False - raise - - - # Crypto store S3 round-trip - def download_crypto_store(store_path: str, s3_key: str): - data = s3_get_bytes(s3_key) - if not data: - print(f" No existing crypto store at {s3_key} — starting fresh.") - return - os.makedirs(store_path, exist_ok=True) - with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: - tar.extractall(store_path, filter="data") - print(f" Crypto store restored from S3 ({len(data)} bytes).") - - - def upload_crypto_store(store_path: str, s3_key: str): - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tar: - tar.add(store_path, arcname=".") - data = buf.getvalue() - s3_put(s3_key, data) - print(f" Crypto store saved to S3 ({len(data)} bytes).") - - - # Session management (password login, once only) - def load_session(store_path: str): - """Return stored session dict or None if not yet created.""" - session_file = os.path.join(store_path, "session.json") - if not os.path.exists(session_file): - return None - try: - with open(session_file) as f: - return json.load(f) - except Exception: - return None - - - def save_session(store_path: str, session: dict): - os.makedirs(store_path, exist_ok=True) - with open(os.path.join(store_path, "session.json"), "w") as f: - json.dump(session, f) - - - async def ensure_session(account: dict) -> dict: - """ - Return a session dict with access_token, device_id, ssss_bootstrapped. - Performs a password login on first run. - """ - session = load_session(account["store"]) - if session: - return session - - print(f" First run: logging in with password for {account['user_id']} ...") - tmp_client = AsyncClient(HOMESERVER, account["user_id"]) - try: - resp = await tmp_client.login( - password=account["password"], - device_name="matrix-backup", - ) - if isinstance(resp, LoginError): - raise RuntimeError(f"Login failed: {resp}") - finally: - await tmp_client.close() - - session = { - "access_token": resp.access_token, - "device_id": resp.device_id, - "ssss_bootstrapped": False, - } - save_session(account["store"], session) - print(f" Logged in. Device ID: {resp.device_id}") - return session - - - # SSSS recovery key decoding - _BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - - - def decode_recovery_key(key_str: str) -> bytes: - """ - Decode a Matrix recovery/security key to its 32 raw key bytes. - Accepts keys with or without spaces/dashes. - """ - cleaned = key_str.replace(" ", "").replace("-", "") - n = 0 - for ch in cleaned: - n = n * 58 + _BASE58.index(ch) - raw = n.to_bytes(35, "big") - if raw[0] != 0x8B or raw[1] != 0x01: - raise ValueError("Invalid recovery key prefix") - parity = 0 - for b in raw[:34]: - parity ^= b - if parity != raw[34]: - raise ValueError("Invalid recovery key parity byte") - return raw[2:34] # 32-byte key - - - # SSSS AES-HMAC-SHA2 decryption - def _ssss_derive(raw_key: bytes, secret_name: str): - """Derive (aes_key, mac_key) for a given secret name via HKDF-SHA256.""" - derived = HKDF( - algorithm=hashes.SHA256(), - length=64, - salt=b"\x00" * 32, - info=secret_name.encode(), - backend=default_backend(), - ).derive(raw_key) - return derived[:32], derived[32:] - - - def ssss_decrypt(encrypted: dict, raw_key: bytes, secret_name: str) -> bytes: - """Decrypt one entry from the 'encrypted' map of an SSSS account-data event.""" - aes_key, mac_key = _ssss_derive(raw_key, secret_name) - ciphertext = base64.b64decode(encrypted["ciphertext"]) - iv = base64.b64decode(encrypted["iv"]) - stored_mac = base64.b64decode(encrypted["mac"]) - - expected_mac = hmac_lib.new(mac_key, ciphertext, hashlib.sha256).digest() - if not hmac_lib.compare_digest(expected_mac, stored_mac): - raise ValueError("SSSS MAC mismatch — wrong recovery key?") - - cipher = Cipher(algorithms.AES(aes_key), modes.CTR(iv), backend=default_backend()) - dec = cipher.decryptor() - return dec.update(ciphertext) + dec.finalize() - - - # Megolm export file creation (for client.import_keys) - def build_megolm_export(sessions: list, passphrase: str = "_backup_import_") -> bytes: - """ - Encode a list of session dicts into the standard Megolm key export format - so they can be loaded with AsyncClient.import_keys(). - - sessions: [{"algorithm","room_id","sender_key","session_id","session_key", - "sender_claimed_keys","forwarding_curve25519_key_chain"}] # gitleaks:allow - """ - salt = os.urandom(16) - iv_bytes = bytearray(os.urandom(16)) - iv_bytes[0] &= 0x7F # ensure highest bit is 0 as spec requires - iv = bytes(iv_bytes) - iterations = 100_000 - - kdf = PBKDF2HMAC( - algorithm=hashes.SHA512(), - length=64, - salt=salt, - iterations=iterations, - backend=default_backend(), - ) - key_material = kdf.derive(passphrase.encode()) - aes_key = key_material[:32] - hmac_key = key_material[32:] - - plaintext = json.dumps(sessions).encode() - cipher = Cipher(algorithms.AES(aes_key), modes.CTR(iv), backend=default_backend()) - encryptor = cipher.encryptor() - ciphertext = encryptor.update(plaintext) + encryptor.finalize() - - payload = b"\x01" + salt + iv + struct.pack(">I", iterations) + ciphertext - mac = hmac_lib.new(hmac_key, payload, hashlib.sha256).digest() - - encoded = base64.b64encode(payload + mac).decode() - lines = "\n".join(encoded[i : i + 76] for i in range(0, len(encoded), 76)) - return ( - "-----BEGIN MEGOLM SESSION DATA-----\n" - + lines - + "\n-----END MEGOLM SESSION DATA-----\n" - ).encode() - - - # SSSS bootstrap: fetch and import key backup - def _parse_account_data(sync_resp) -> dict: - """Extract account data events from a SyncResponse into a {type: event} dict.""" - result = {} - for event in getattr(sync_resp, "account_data", []): - key = getattr(event, "type", None) or getattr(event, "event_type", None) - if key: - result[key] = event - return result - - - def _fetch_account_data_event(access_token: str, user_id: str, event_type: str) -> dict | None: - """ - Fetch a single account data event directly from the server (read-only GET). - Returns the content dict, or None if the event doesn't exist. - Incremental syncs don't re-deliver unchanged account data, so we fetch directly. - """ - import urllib.request, urllib.error - url = f"{HOMESERVER}/_matrix/client/v3/user/{urllib.request.quote(user_id, safe='')}/account_data/{urllib.request.quote(event_type, safe='')}" - req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"}) - try: - with urllib.request.urlopen(req, timeout=15) as r: - return json.loads(r.read()) - except urllib.error.HTTPError as e: - if e.code == 404: - return None - raise - - - async def _decrypt_ssss_backup_key(client: AsyncClient, user_id: str, recovery_key_str: str) -> str | None: - """ - Decrypt and return the base64-encoded megolm backup private key from SSSS. - Fetches the required account data events directly from the server (read-only GETs) - so this works on both first run and incremental syncs. - Returns None if SSSS data is missing or decryption fails. - """ - raw_key = decode_recovery_key(recovery_key_str) - - default_key_content = _fetch_account_data_event(client.access_token, user_id, "m.secret_storage.default_key") - if not default_key_content: - print(" Warning: no m.secret_storage.default_key in account data") - return None - key_id = default_key_content.get("key") - if not key_id: - print(" Warning: m.secret_storage.default_key has no 'key' field") - return None - - backup_secret_content = _fetch_account_data_event(client.access_token, user_id, "m.megolm_backup.v1") - if not backup_secret_content: - print(" Warning: no m.megolm_backup.v1 in account data") - return None - encrypted_map = backup_secret_content.get("encrypted", {}) - if key_id not in encrypted_map: - print(f" Warning: key_id {key_id} not found in backup secret encrypted map") - return None - - backup_key_bytes = ssss_decrypt(encrypted_map[key_id], raw_key, "m.megolm_backup.v1") - return backup_key_bytes.decode().strip() - - - def _olm_pk_decrypt(private_key_bytes: bytes, ciphertext_b64: str, mac_b64: str, ephemeral_b64: str) -> str: - """ - Decrypt an olm PK-encrypted payload using the cryptography library directly, - avoiding the need for PkDecryption.from_private_key (requires libolm >= 3.2.3). - - Olm PK decryption spec: - 1. ECDH(our_private, ephemeral_public) → shared_secret (X25519) - 2. HKDF-SHA256(salt=\\x00*32, ikm=shared_secret, info=b"OLM_KEYS") → 80 bytes - → aes_key[0:32], mac_key[32:64], iv[64:80] - 3. Verify HMAC-SHA256(mac_key, ciphertext) == mac - 4. AES-256-CBC decrypt(aes_key, iv, ciphertext), unpad PKCS7 - """ - from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey - from cryptography.hazmat.primitives import padding as sym_padding - - def _b64decode(s: str) -> bytes: - return base64.b64decode(s + "=" * (-len(s) % 4)) - - ciphertext = _b64decode(ciphertext_b64) - mac = _b64decode(mac_b64) - ephemeral_pub = _b64decode(ephemeral_b64) - - # X25519 ECDH - priv = X25519PrivateKey.from_private_bytes(private_key_bytes) - shared_secret = priv.exchange(X25519PublicKey.from_public_bytes(ephemeral_pub)) - - # HKDF key derivation - derived = HKDF( - algorithm=hashes.SHA256(), - length=80, - salt=b"\x00" * 32, - info=b"OLM_KEYS", - backend=default_backend(), - ).derive(shared_secret) - aes_key = derived[:32] - mac_key = derived[32:64] - iv = derived[64:80] - - # MAC verification - expected_mac = hmac_lib.new(mac_key, ciphertext, hashlib.sha256).digest() - if not hmac_lib.compare_digest(expected_mac, mac): - raise ValueError("BAD_MESSAGE_MAC") - - # AES-256-CBC decryption + PKCS7 unpad - cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv), backend=default_backend()) - dec = cipher.decryptor() - padded = dec.update(ciphertext) + dec.finalize() - unpadder = sym_padding.PKCS7(128).unpadder() - plaintext = unpadder.update(padded) + unpadder.finalize() - return plaintext.decode() - - - async def import_key_backup(client: AsyncClient, backup_private_key_b64: str): - """ - Fetch all sessions from the server-side key backup, decrypt them with the - backup private key, and import them into the local crypto store. - Called on every run so newly backed-up sessions are always available. - """ - import urllib.request - - # Resolve backup version and expected public key - url = f"{HOMESERVER}/_matrix/client/v3/room_keys/version" - req = urllib.request.Request( - url, headers={"Authorization": f"Bearer {client.access_token}"} - ) - with urllib.request.urlopen(req, timeout=15) as r: - backup_info = json.loads(r.read()) - backup_version = backup_info["version"] - print(f" Key backup version: {backup_version}") - - # Fetch all backed-up sessions - url = f"{HOMESERVER}/_matrix/client/v3/room_keys/keys?version={backup_version}" - req = urllib.request.Request( - url, headers={"Authorization": f"Bearer {client.access_token}"} - ) - with urllib.request.urlopen(req, timeout=60) as r: - rooms = json.loads(r.read()).get("rooms", {}) - print(f" Fetched key backup: {len(rooms)} rooms") - - # Decode the private key (Matrix stores as unpadded base64) - private_key_bytes = base64.b64decode(backup_private_key_b64 + "=" * (-len(backup_private_key_b64) % 4)) - - sessions_to_import = [] - for room_id, room_data in rooms.items(): - for session_id, session_info in room_data.get("sessions", {}).items(): - sd = session_info.get("session_data", {}) - try: - plaintext = _olm_pk_decrypt( - private_key_bytes, - sd["ciphertext"], - sd["mac"], - sd["ephemeral"], - ) - session_obj = json.loads(plaintext) - sessions_to_import.append({ - "algorithm": "m.megolm.v1.aes-sha2", - "room_id": room_id, - "session_id": session_id, - "session_key": session_obj["session_key"], - "sender_key": session_obj.get("sender_key", ""), - "sender_claimed_keys": session_obj.get("sender_claimed_keys", {}), - "forwarding_curve25519_key_chain": - session_obj.get("forwarding_curve25519_key_chain", []), - }) - except Exception as e: - print(f" Warning: failed to decrypt session {session_id} in {room_id}: {e}") - - print(f" Decrypted {len(sessions_to_import)} sessions; importing...") - if sessions_to_import: - tmp = tempfile.mktemp(suffix=".megolm") - try: - with open(tmp, "wb") as f: - f.write(build_megolm_export(sessions_to_import)) - await client.import_keys(tmp, "_backup_import_") - finally: - if os.path.exists(tmp): - os.unlink(tmp) - print(f" Imported {len(sessions_to_import)} sessions from key backup.") - - - async def bootstrap_crypto_from_ssss(client: AsyncClient, user_id: str, recovery_key_str: str): - """ - First-run only: fetch SSSS account data directly from the server (read-only GETs), - decrypt the backup key, and return it for storage in session.json. - """ - print(" Bootstrapping crypto from SSSS...") - backup_private_key_b64 = await _decrypt_ssss_backup_key(client, user_id, recovery_key_str) - if not backup_private_key_b64: - print(" Warning: could not decrypt SSSS backup key — skipping") - return None - print(" Decrypted megolm backup private key from SSSS.") - return backup_private_key_b64 - - - # Room helpers - def safe_room_key(room_id: str) -> str: - return room_id.replace("/", "_").replace(":", "_") - - - def derive_server_hints(room) -> list: - counter = Counter() - # matrix-nio exposes room members as room.users (dict of user_id -> RoomMember) - for user_id in room.users: - if ":" in user_id: - counter[user_id.split(":", 1)[1]] += 1 - return [s for s, _ in counter.most_common(5)] - - - def get_aliases(room) -> list: - aliases = [] - if room.canonical_alias: - aliases.append(room.canonical_alias) - if hasattr(room, "alt_aliases"): - for a in room.alt_aliases: - if a not in aliases: - aliases.append(a) - if hasattr(room, "aliases") and isinstance(room.aliases, dict): - for server_aliases in room.aliases.values(): - for a in server_aliases: - if a not in aliases: - aliases.append(a) - return aliases - - - async def get_room_type(client, room_id: str, room, dm_rooms: set) -> str: - if room_id in dm_rooms: - return "dm" - try: - resp = await client.room_get_state_event(room_id, "m.room.create", "") - if hasattr(resp, "content") and resp.content.get("type") == "m.space": - return "space" - except Exception: - pass - return "normal" - - - # Media helpers - _MXC_RE = re.compile(r"^mxc://([^/]+)/(.+)$") - - - def parse_mxc(mxc_url: str): - m = _MXC_RE.match(mxc_url or "") - return (m.group(1), m.group(2)) if m else (None, None) - - - def ext_from_content_type(ct: str) -> str: - ext = mimetypes.guess_extension(ct.split(";")[0].strip()) or "" - return {".jpe": ".jpg", ".jpeg": ".jpg"}.get(ext, ext) - - - async def store_media(client, mxc_url: str, prefix: str, label: str = "media"): - server, media_id = parse_mxc(mxc_url) - if not server or not media_id: - return - base_key = f"{prefix}/{label}/{server}/{media_id}" - if s3_exists(base_key): - return - resp = await client.download(server_name=server, media_id=media_id) - if isinstance(resp, DownloadError): - print(f" Warning: failed to download {mxc_url}") - return - ext = ext_from_content_type(resp.content_type or "") - key = base_key + ext if ext else base_key - s3_put(key, resp.body, resp.content_type or "application/octet-stream") - print(f" Stored {key} ({len(resp.body)} bytes)") - - - # Profile change tracking - async def record_profile_update(client, src: dict, prefix: str): - state_key = src.get("state_key", "") - if not state_key.endswith(OUR_HOMESERVER_SUFFIX): - return - content = src.get("content", {}) - prev_content = src.get("unsigned", {}).get("prev_content", {}) - new_name = content.get("displayname") - prev_name = prev_content.get("displayname") - new_avatar = content.get("avatar_url") - prev_avatar = prev_content.get("avatar_url") - if new_name == prev_name and new_avatar == prev_avatar: - return - record = { - "ts": src.get("origin_server_ts"), - "event_id": src.get("event_id"), - "user_id": state_key, - "room_id": src.get("room_id"), - } - if new_name != prev_name: - record["displayname_old"] = prev_name - record["displayname_new"] = new_name - if new_avatar != prev_avatar: - record["avatar_old"] = prev_avatar - record["avatar_new"] = new_avatar - if new_avatar: - await store_media(client, new_avatar, prefix, label="avatars") - log_key = f"{prefix}/profile-updates.jsonl" - existing = s3_get_bytes(log_key) or b"" - s3_put(log_key, existing + json.dumps(record).encode() + b"\n", "application/x-ndjson") - - - # Media message types to download - MEDIA_MSGTYPES = {"m.image", "m.file", "m.video", "m.audio"} - - - async def _process_event(client, src: dict, room_id: str, prefix: str, is_dm: bool): - """Handle media download and profile-update side-effects for one event.""" - content = src.get("content", {}) - event_type = src.get("type", "") - sender = str(src.get("sender", "")) - - # Media: all types in DMs; only our-user files in other rooms - if event_type == "m.room.message" and content.get("msgtype") in MEDIA_MSGTYPES: - if is_dm or sender.endswith(OUR_HOMESERVER_SUFFIX): - mxc = content.get("url", "") - if mxc: - await store_media(client, mxc, prefix, label="media") - - # Stickers (m.sticker) always have a url directly in content - if event_type == "m.sticker": - if is_dm or sender.endswith(OUR_HOMESERVER_SUFFIX): - mxc = content.get("url", "") - if mxc: - await store_media(client, mxc, prefix, label="media") - - # Profile changes (display name / avatar in m.room.member state events) - if event_type == "m.room.member": - src["room_id"] = room_id - await record_profile_update(client, src, prefix) - - - def _event_to_record(src: dict) -> dict: - return { - "event_id": src.get("event_id"), - "sender": src.get("sender"), - "type": src.get("type"), - "origin_server_ts": src.get("origin_server_ts"), - "content": src.get("content", {}), - } - - - # History pagination - async def paginate_history(client, room_id: str, prefix: str, is_dm: bool = False): - room_key = safe_room_key(room_id) - cursor_s3 = f"{prefix}/history-cursor/{room_key}.json" - - cursor_data = s3_get_json(cursor_s3) - - if not cursor_data: - # First time we've seen this room. - room = client.rooms.get(room_id) - token = getattr(room, "prev_batch", None) - if not token: - return - - if is_dm: - # Paginate backward through the full DM history before setting the cursor. - print(f" DM first-run: fetching full history for {room_id} ...") - messages = [] - back_token = token - pages = 0 - while True: - resp = await client.room_messages( - room_id, - start=back_token, - limit=100, - direction=MessageDirection.back, - ) - if isinstance(resp, RoomMessagesError): - print(f" Warning: room_messages error for {room_id}: {resp}") - break - if not resp.chunk: - break - for event in resp.chunk: - src = event.source if hasattr(event, "source") else {} - messages.append(_event_to_record(src)) - await _process_event(client, src, room_id, prefix, is_dm=True) - new_end = getattr(resp, "end", None) - if not new_end or new_end == back_token: - break - back_token = new_end - pages += 1 - - if messages: - # Events arrived in reverse-chron order; reverse so oldest is first. - messages.reverse() - history_s3 = f"{prefix}/history/{room_key}/{DATE_STR}.jsonl" - chunk = b"\n".join(json.dumps(m).encode() for m in messages) + b"\n" - s3_put(history_s3, chunk, "application/x-ndjson") - print(f" DM history: {len(messages)} events ({pages} pages) for {room_id}") - - # Record the forward cursor so subsequent runs pick up new messages. - s3_put(cursor_s3, json.dumps({"token": token}).encode(), "application/json") - print(f" History cursor initialised for {room_id}") - return - - # Subsequent runs: paginate forward from the stored cursor. - start_token = cursor_data["token"] - messages = [] - next_token = start_token - history_s3 = f"{prefix}/history/{room_key}/{DATE_STR}.jsonl" - - for _ in range(100): - resp = await client.room_messages( - room_id, - start=next_token, - limit=100, - direction=MessageDirection.forward, - ) - if isinstance(resp, RoomMessagesError): - print(f" Warning: room_messages error for {room_id}: {resp}") - break - if not resp.chunk: - break - for event in resp.chunk: - src = event.source if hasattr(event, "source") else {} - messages.append(_event_to_record(src)) - await _process_event(client, src, room_id, prefix, is_dm) - new_end = getattr(resp, "end", None) - if not new_end or new_end == next_token: - next_token = None - break - next_token = new_end - - if messages: - existing = s3_get_bytes(history_s3) or b"" - chunk = b"\n".join(json.dumps(m).encode() for m in messages) + b"\n" - s3_put(history_s3, existing + chunk, "application/x-ndjson") - print(f" History: +{len(messages)} events for {room_id}") - - if next_token and next_token != start_token: - s3_put(cursor_s3, json.dumps({"token": next_token}).encode(), "application/json") - - - # Per-account backup - async def backup_account(account: dict): - prefix = account["prefix"] - print(f"\n=== Backing up {account['user_id']} ===") - - # 1. Restore crypto store from S3 (must happen before opening the client) - store_s3_key = f"{prefix}/crypto-store.tar.gz" - download_crypto_store(account["store"], store_s3_key) - - # 2. Ensure we have a session (password-login on first run, persisted inside the store) - session = await ensure_session(account) - - # 3. Open the client (only after store is ready and session is known) - config = AsyncClientConfig(store_sync_tokens=True) - client = AsyncClient( - HOMESERVER, - account["user_id"], - store_path=account["store"], - config=config, - ) - client.restore_login( - user_id=account["user_id"], - device_id=session["device_id"], - access_token=session["access_token"], - ) - - try: - # Load sync token for incremental syncs - token_file = os.path.join(account["store"], "sync_token.json") - since = None - if os.path.exists(token_file): - try: - with open(token_file) as f: - since = json.load(f).get("next_batch") - except Exception: - pass - - print(f" Syncing (full_state=True, since={since!r})...") - sync_resp = await client.sync(full_state=True, since=since, timeout=60000) - with open(token_file, "w") as f: - json.dump({"next_batch": sync_resp.next_batch}, f) - print(f" Sync done. Rooms: {len(client.rooms)}") - - # Account data from sync response (nio doesn't accumulate it on the client object) - account_data = _parse_account_data(sync_resp) - - # 4. SSSS bootstrap on first login (after sync so account_data is available). - # Only reads from account_data and decrypts locally — no server writes. - if not session.get("ssss_bootstrapped") or not session.get("backup_private_key"): - try: - backup_private_key_b64 = await bootstrap_crypto_from_ssss( - client, account["user_id"], account["ssss_key"] - ) - if backup_private_key_b64: - session["backup_private_key"] = backup_private_key_b64 - session["ssss_bootstrapped"] = True - save_session(account["store"], session) - except Exception as e: - print(f" Warning: SSSS bootstrap failed: {e}", file=sys.stderr) - - # 5. Import key backup on every run so newly backed-up sessions are available. - if session.get("backup_private_key"): - try: - await import_key_backup(client, session["backup_private_key"]) - except Exception as e: - print(f" Warning: key backup import failed: {e}", file=sys.stderr) - else: - print(" Skipping key backup import (no backup_private_key in session yet)") - - # 6. DM room set — prefer list_direct_rooms() (nio PR #451) with fallback - dm_rooms: set = set() - try: - dm_resp = await client.list_direct_rooms() - if hasattr(dm_resp, "rooms") and isinstance(dm_resp.rooms, dict): - for room_ids in dm_resp.rooms.values(): - if isinstance(room_ids, list): - dm_rooms.update(room_ids) - else: - raise AttributeError("unexpected list_direct_rooms response") - except Exception: - # Fallback: parse m.direct from sync account data manually - ad_event = account_data.get("m.direct") - dm_map = getattr(ad_event, "content", {}) or {} if ad_event else {} - for room_ids in dm_map.values(): - if isinstance(room_ids, list): - dm_rooms.update(room_ids) - - # 7. Room list - print(" Building room list...") - rooms_data = [] - for room_id, room in client.rooms.items(): - room_type = await get_room_type(client, room_id, room, dm_rooms) - aliases = get_aliases(room) - via = derive_server_hints(room) - rooms_data.append({ - "room_id": room_id, - "name": room.display_name or room.name or room_id, - "type": room_type, - "aliases": aliases, - "canonical_alias": room.canonical_alias, - "via": via, - "member_count": room.member_count, - "encrypted": room.encrypted, - }) - print(f" [{room_type:6s}] {room.display_name or room_id}") - rooms_json = json.dumps(rooms_data, indent=2).encode() - s3_put(f"{prefix}/rooms-{DATE_STR}.json", rooms_json, "application/json") - s3_put(f"{prefix}/rooms-latest.json", rooms_json, "application/json") - print(f" Uploaded rooms ({len(rooms_data)} rooms)") - - # 8. E2EE key export - print(" Exporting E2EE keys...") - try: - key_file = tempfile.mktemp(suffix=".bin") - await client.export_keys(key_file, KEY_EXPORT_PASSPHRASE) - with open(key_file, "rb") as f: - key_data = f.read() - os.unlink(key_file) - s3_put(f"{prefix}/crypto-keys-{DATE_STR}.bin", key_data) - s3_put(f"{prefix}/crypto-keys-latest.bin", key_data) - print(f" Uploaded crypto-keys-latest.bin ({len(key_data)} bytes)") - except Exception as e: - print(f" Warning: key export failed: {e}", file=sys.stderr) - - # 9. Incremental history + media + profile updates - print(" Backing up history / media / profile updates...") - for room_id in list(client.rooms.keys()): - try: - await paginate_history(client, room_id, prefix, is_dm=(room_id in dm_rooms)) - except Exception as e: - print(f" Warning: history error for {room_id}: {e}", file=sys.stderr) - - finally: - # Close the client BEFORE uploading the store so nothing is mid-write - await client.close() - upload_crypto_store(account["store"], store_s3_key) - - print(f" Done: {account['user_id']}") - - - # Entry point - async def main(): - for account in ACCOUNTS: - await backup_account(account) - print("\nAll accounts backed up successfully.") - - - if __name__ == "__main__": - asyncio.run(main()) diff --git a/apps/talos_cluster/matrix-backup/backup-tool/Dockerfile b/apps/talos_cluster/matrix-backup/backup-tool/Dockerfile @@ -0,0 +1,28 @@ +# Multi-stage build — produces a fully static binary (no CGO, no libolm). +# The goolm build tag enables mautrix-go's pure-Go Olm/Megolm implementation. + +# Build stage runs on the native host arch; cross-compiles via GOARCH. +FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS builder +ARG TARGETARCH + +WORKDIR /app + +# Copy module files first for layer caching. go.sum is generated by go mod tidy +# at build time since we cannot run it locally in the gitops pipeline. +COPY go.mod ./ +COPY . . + +RUN go mod tidy +RUN CGO_ENABLED=0 GOARCH=${TARGETARCH} \ + go build \ + -tags goolm \ + -ldflags="-s -w" \ + -o /matrix-backup \ + . + +# Runtime stage — distroless/static has no shell, no libc, minimal attack surface. +FROM gcr.io/distroless/static:nonroot + +COPY --from=builder /matrix-backup /matrix-backup + +ENTRYPOINT ["/matrix-backup"] diff --git a/apps/talos_cluster/matrix-backup/backup-tool/go.mod b/apps/talos_cluster/matrix-backup/backup-tool/go.mod @@ -0,0 +1,12 @@ +module github.com/mtrnord/matrix-backup + +go 1.23 + +require ( + github.com/aws/aws-sdk-go-v2 v1.30.3 + github.com/aws/aws-sdk-go-v2/config v1.27.26 + github.com/aws/aws-sdk-go-v2/credentials v1.17.26 + github.com/aws/aws-sdk-go-v2/service/s3 v1.61.0 + golang.org/x/crypto v0.27.0 + maunium.net/go/mautrix v0.26.4 +) diff --git a/apps/talos_cluster/matrix-backup/backup-tool/main.go b/apps/talos_cluster/matrix-backup/backup-tool/main.go @@ -0,0 +1,1129 @@ +// Matrix backup tool — rewrites the Python matrix-nio backup in Go using mautrix-go. +// +// Key improvements over the Python version: +// - SSSS decryption uses mautrix-go's proven implementation (fixes BAD_MESSAGE_MAC) +// - Olm PK decryption uses mautrix-go with the goolm pure-Go backend (no libolm needed) +// - No runtime pip install; compiled static binary via Dockerfile multi-stage build +// - No SQLite/OlmMachine needed; session and sync token stored in S3 +// - Megolm export format implemented in pure Go (PBKDF2-SHA512 + AES-CTR + HMAC-SHA256) +// +// Build: CGO_ENABLED=0 go build -tags goolm -o matrix-backup . +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "golang.org/x/crypto/pbkdf2" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/backup" + "maunium.net/go/mautrix/crypto/ssss" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Config +// ───────────────────────────────────────────────────────────────────────────── + +const ourServerName = "mtrnord.blog" + +type accountCfg struct { + UserID id.UserID + Password string + SSSSKey string + Prefix string + StoreDir string +} + +var ( + homeserver = mustEnv("HOMESERVER") + s3Endpoint = mustEnv("S3_ENDPOINT") + s3BucketName = mustEnv("S3_BUCKET") + s3AccessKey = mustEnv("S3_ACCESS_KEY") + s3SecretKey = mustEnv("S3_SECRET_KEY") + keyExportPass = mustEnv("KEY_EXPORT_PASSPHRASE") + dateStr = time.Now().UTC().Format("20060102") + + accounts = []accountCfg{ + { + UserID: "@mtrnord:mtrnord.blog", + Password: os.Getenv("MTRNORD_PASSWORD"), + SSSSKey: os.Getenv("MTRNORD_SSSS_KEY"), + Prefix: "mtrnord", + StoreDir: "/data/crypto/mtrnord", + }, + { + UserID: "@lexi:mtrnord.blog", + Password: os.Getenv("LEXI_PASSWORD"), + SSSSKey: os.Getenv("LEXI_SSSS_KEY"), + Prefix: "lexi", + StoreDir: "/data/crypto/lexi", + }, + } +) + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + slog.Error("Required environment variable not set", "key", key) + os.Exit(1) + } + return v +} + +// ───────────────────────────────────────────────────────────────────────────── +// S3 helpers +// ───────────────────────────────────────────────────────────────────────────── + +var s3c *s3.Client + +func initS3(ctx context.Context) error { + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion("hel1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + s3AccessKey, s3SecretKey, "", + )), + awsconfig.WithBaseEndpoint("https://"+s3Endpoint), + ) + if err != nil { + return err + } + s3c = s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true + }) + return nil +} + +func s3Get(ctx context.Context, key string) ([]byte, error) { + out, err := s3c.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s3BucketName), + Key: aws.String(key), + }) + if err != nil { + var nsk *s3types.NoSuchKey + if errors.As(err, &nsk) { + return nil, nil + } + return nil, err + } + defer out.Body.Close() + return io.ReadAll(out.Body) +} + +func s3GetJSON(ctx context.Context, key string, out interface{}) error { + data, err := s3Get(ctx, key) + if err != nil || data == nil { + return err + } + return json.Unmarshal(data, out) +} + +func s3Put(ctx context.Context, key string, data []byte, contentType string) error { + _, err := s3c.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s3BucketName), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + }) + return err +} + +func s3Exists(ctx context.Context, key string) bool { + _, err := s3c.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s3BucketName), + Key: aws.String(key), + }) + return err == nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// Crypto-store tarball (persists sessions and sync tokens across CronJob runs) +// ───────────────────────────────────────────────────────────────────────────── + +func downloadStore(ctx context.Context, storeDir, s3Key string) error { + data, err := s3Get(ctx, s3Key) + if err != nil { + return err + } + if data == nil { + slog.Info("No existing store in S3, starting fresh", "key", s3Key) + return nil + } + if err := os.MkdirAll(storeDir, 0700); err != nil { + return err + } + gr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer gr.Close() + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + target := storeDir + "/" + hdr.Name + switch hdr.Typeflag { + case tar.TypeDir: + os.MkdirAll(target, 0700) //nolint:errcheck + case tar.TypeReg: + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + f.Close() + } + } + slog.Info("Store restored from S3", "key", s3Key, "bytes", len(data)) + return nil +} + +func uploadStore(ctx context.Context, storeDir, s3Key string) error { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + if err := addDirToTar(tw, storeDir, "."); err != nil { + return err + } + tw.Close() //nolint:errcheck + gw.Close() //nolint:errcheck + data := buf.Bytes() + if err := s3Put(ctx, s3Key, data, "application/gzip"); err != nil { + return err + } + slog.Info("Store saved to S3", "key", s3Key, "bytes", len(data)) + return nil +} + +func addDirToTar(tw *tar.Writer, baseDir, arcBase string) error { + entries, err := os.ReadDir(baseDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, e := range entries { + srcPath := baseDir + "/" + e.Name() + arcPath := arcBase + "/" + e.Name() + if e.IsDir() { + _ = tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: arcPath + "/", + Mode: 0700, + }) + if err := addDirToTar(tw, srcPath, arcPath); err != nil { + return err + } + continue + } + info, err := e.Info() + if err != nil { + continue + } + f, err := os.Open(srcPath) + if err != nil { + continue + } + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: arcPath, + Size: info.Size(), + Mode: 0600, + }); err != nil { + f.Close() + return err + } + _, copyErr := io.Copy(tw, f) + f.Close() + if copyErr != nil { + return copyErr + } + } + return nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session management +// ───────────────────────────────────────────────────────────────────────────── + +type sessionData struct { + AccessToken string `json:"access_token"` + DeviceID id.DeviceID `json:"device_id"` +} + +func ensureSession(ctx context.Context, client *mautrix.Client, acc accountCfg, s3Key string) (*sessionData, error) { + var sess sessionData + if err := s3GetJSON(ctx, s3Key, &sess); err != nil { + return nil, fmt.Errorf("load session: %w", err) + } + if sess.AccessToken != "" { + slog.Info("Restored existing session", "device_id", sess.DeviceID) + client.UserID = acc.UserID + client.DeviceID = sess.DeviceID + client.AccessToken = sess.AccessToken + return &sess, nil + } + + slog.Info("First run — logging in with password", "user_id", acc.UserID) + resp, err := client.Login(ctx, &mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: string(acc.UserID), + }, + Password: acc.Password, + DeviceName: "matrix-backup", + }) + if err != nil { + return nil, fmt.Errorf("login: %w", err) + } + sess = sessionData{ + AccessToken: resp.AccessToken, + DeviceID: resp.DeviceID, + } + client.UserID = resp.UserID + client.DeviceID = resp.DeviceID + client.AccessToken = resp.AccessToken + slog.Info("Logged in", "device_id", resp.DeviceID) + + data, err := json.Marshal(sess) + if err != nil { + return nil, err + } + if err := s3Put(ctx, s3Key, data, "application/json"); err != nil { + return nil, fmt.Errorf("save session: %w", err) + } + return &sess, nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// Matrix API helpers +// ───────────────────────────────────────────────────────────────────────────── + +func matrixGetJSON(ctx context.Context, client *mautrix.Client, path string, out interface{}) error { + base := strings.TrimRight(client.HomeserverURL.String(), "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+client.AccessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + return json.Unmarshal(body, out) +} + +func getAccountData(ctx context.Context, client *mautrix.Client, eventType string, out interface{}) error { + path := "/_matrix/client/v3/user/" + + url.PathEscape(client.UserID.String()) + + "/account_data/" + + url.PathEscape(eventType) + return matrixGetJSON(ctx, client, path, out) +} + +// ───────────────────────────────────────────────────────────────────────────── +// SSSS + Megolm key backup +// ───────────────────────────────────────────────────────────────────────────── + +type defaultKeyEventContent struct { + Key string `json:"key"` +} + +// encryptedSecretContent matches the `{"encrypted": {"<keyID>": {...}}}` format. +type encryptedSecretContent struct { + Encrypted map[string]ssss.EncryptedKeyData `json:"encrypted"` +} + +type keyBackupVersionResp struct { + Version string `json:"version"` + Algorithm string `json:"algorithm"` +} + +type keyBackupRoomSession struct { + FirstMessageIndex int `json:"first_message_index"` + ForwardedCount int `json:"forwarded_count"` + IsVerified bool `json:"is_verified"` + SessionData json.RawMessage `json:"session_data"` +} + +type keyBackupRoom struct { + Sessions map[string]keyBackupRoomSession `json:"sessions"` +} + +type keyBackupAllRooms struct { + Rooms map[id.RoomID]keyBackupRoom `json:"rooms"` +} + +// exportedSessionEntry matches the standard Megolm key export session format +// (https://spec.matrix.org/v1.9/client-server-api/#key-exports). +type exportedSessionEntry struct { + Algorithm string `json:"algorithm"` + ForwardingCurve25519KeyChain []string `json:"forwarding_curve25519_key_chain"` + RoomID string `json:"room_id"` + SenderClaimedKeys map[string]string `json:"sender_claimed_keys"` + SenderKey string `json:"sender_key"` + SessionID string `json:"session_id"` + SessionKey string `json:"session_key"` +} + +func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recoveryKeyStr, prefix string) error { + // 1. Resolve the default SSSS key ID from account data. + var defaultKey defaultKeyEventContent + if err := getAccountData(ctx, client, "m.secret_storage.default_key", &defaultKey); err != nil { + return fmt.Errorf("get default key: %w", err) + } + if defaultKey.Key == "" { + return fmt.Errorf("m.secret_storage.default_key has no 'key' field") + } + keyID := defaultKey.Key + slog.Info("SSSS default key ID", "key_id", keyID) + + // 2. Fetch the key metadata for the default key. + // The event type is m.secret_storage.key.<keyID>. + var keyMetadata ssss.KeyMetadata + if err := getAccountData(ctx, client, "m.secret_storage.key."+keyID, &keyMetadata); err != nil { + return fmt.Errorf("get key metadata: %w", err) + } + + // 3. Verify the recovery key against the stored metadata and derive the raw key. + // VerifyRecoveryKey may return ErrUnverifiableKey alongside a valid key when + // no MAC/IV is stored in the metadata — treat that as success. + sssKey, err := keyMetadata.VerifyRecoveryKey(keyID, recoveryKeyStr) + if err != nil && !errors.Is(err, ssss.ErrUnverifiableKey) { + return fmt.Errorf("verify recovery key: %w", err) + } + slog.Info("Recovery key verified (or unverifiable but accepted)") + + // 4. Fetch the encrypted backup secret and decrypt with the SSSS key. + var backupSecret encryptedSecretContent + if err := getAccountData(ctx, client, "m.megolm_backup.v1", &backupSecret); err != nil { + return fmt.Errorf("get backup secret: %w", err) + } + encData, ok := backupSecret.Encrypted[keyID] + if !ok { + return fmt.Errorf("backup secret not encrypted with key %q", keyID) + } + backupKeyRaw, err := sssKey.Decrypt("m.megolm_backup.v1", encData) + if err != nil { + return fmt.Errorf("decrypt backup key from SSSS: %w", err) + } + // backupKeyRaw is the base64-encoded X25519 private key bytes. + backupKeyB64 := strings.TrimSpace(string(backupKeyRaw)) + + // 5. Decode the private key bytes and create the MegolmBackupKey. + privateKeyBytes, err := base64DecodeUnpadded(backupKeyB64) + if err != nil { + return fmt.Errorf("decode backup private key: %w", err) + } + megolmKey, err := backup.MegolmBackupKeyFromBytes(privateKeyBytes) + if err != nil { + return fmt.Errorf("create megolm backup key: %w", err) + } + slog.Info("Decoded megolm backup private key from SSSS") + + // 6. Get the current backup version. + var backupVersion keyBackupVersionResp + if err := matrixGetJSON(ctx, client, "/_matrix/client/v3/room_keys/version", &backupVersion); err != nil { + return fmt.Errorf("get backup version: %w", err) + } + slog.Info("Key backup version", "version", backupVersion.Version) + + // 7. Fetch all backed-up sessions. + var allRooms keyBackupAllRooms + if err := matrixGetJSON(ctx, client, + "/_matrix/client/v3/room_keys/keys?version="+url.QueryEscape(backupVersion.Version), + &allRooms, + ); err != nil { + return fmt.Errorf("get backup keys: %w", err) + } + slog.Info("Fetched key backup", "rooms", len(allRooms.Rooms)) + + // 8. Decrypt each session using mautrix-go's Olm PK (via goolm). + // session_data JSON → EncryptedSessionData[MegolmSessionData] → Decrypt → MegolmSessionData + var sessions []exportedSessionEntry + decOK, decFail := 0, 0 + for roomID, room := range allRooms.Rooms { + for sessionID, sessionInfo := range room.Sessions { + var encSD backup.EncryptedSessionData[backup.MegolmSessionData] + if err := json.Unmarshal(sessionInfo.SessionData, &encSD); err != nil { + slog.Warn("Failed to parse session_data", + "room_id", roomID, "session_id", sessionID, "error", err) + decFail++ + continue + } + sessionData, err := encSD.Decrypt(megolmKey) + if err != nil { + slog.Warn("Failed to decrypt session", + "room_id", roomID, "session_id", sessionID, "error", err) + decFail++ + continue + } + sessions = append(sessions, exportedSessionEntry{ + Algorithm: string(sessionData.Algorithm), + ForwardingCurve25519KeyChain: sessionData.ForwardingKeyChain, + RoomID: string(roomID), + SenderClaimedKeys: map[string]string{"ed25519": string(sessionData.SenderClaimedKeys.Ed25519)}, + SenderKey: string(sessionData.SenderKey), + SessionID: sessionID, + SessionKey: sessionData.SessionKey, + }) + decOK++ + } + } + slog.Info("Session decryption complete", "ok", decOK, "failed", decFail) + + if len(sessions) == 0 { + slog.Warn("No sessions decrypted — skipping key export") + return nil + } + + // 9. Archive raw session JSON for debugging / future re-import. + if rawJSON, err := json.MarshalIndent(sessions, "", " "); err == nil { + _ = s3Put(ctx, prefix+"/backup-sessions-latest.json", rawJSON, "application/json") + _ = s3Put(ctx, prefix+"/backup-sessions-"+dateStr+".json", rawJSON, "application/json") + } + + // 10. Build the standard Megolm key export file and upload. + exportData, err := buildMegolmExport(sessions, keyExportPass) + if err != nil { + return fmt.Errorf("build megolm export: %w", err) + } + if err := s3Put(ctx, prefix+"/crypto-keys-latest.bin", exportData, "application/octet-stream"); err != nil { + return err + } + if err := s3Put(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData, "application/octet-stream"); err != nil { + return err + } + slog.Info("Exported E2EE keys", "sessions", len(sessions), "bytes", len(exportData)) + return nil +} + +func base64DecodeUnpadded(s string) ([]byte, error) { + switch len(s) % 4 { + case 2: + s += "==" + case 3: + s += "=" + } + return base64.StdEncoding.DecodeString(s) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Standard Megolm key export format (pure Go) +// Spec: https://spec.matrix.org/v1.9/client-server-api/#key-exports +// ───────────────────────────────────────────────────────────────────────────── + +func buildMegolmExport(sessions []exportedSessionEntry, passphrase string) ([]byte, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + ivBytes := make([]byte, 16) + if _, err := rand.Read(ivBytes); err != nil { + return nil, err + } + ivBytes[0] &= 0x7F // spec: highest bit of IV must be 0 + + const iterations = 100_000 + keyMaterial := pbkdf2.Key([]byte(passphrase), salt, iterations, 64, sha512.New) + aesKey := keyMaterial[:32] + hmacKey := keyMaterial[32:] + + plaintext, err := json.Marshal(sessions) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + ciphertext := make([]byte, len(plaintext)) + cipher.NewCTR(block, ivBytes).XORKeyStream(ciphertext, plaintext) + + iterBuf := make([]byte, 4) + binary.BigEndian.PutUint32(iterBuf, uint32(iterations)) + + payload := []byte{0x01} + payload = append(payload, salt...) + payload = append(payload, ivBytes...) + payload = append(payload, iterBuf...) + payload = append(payload, ciphertext...) + + mac := hmac.New(sha256.New, hmacKey) + mac.Write(payload) + payload = append(payload, mac.Sum(nil)...) + + encoded := base64.StdEncoding.EncodeToString(payload) + var sb strings.Builder + sb.WriteString("-----BEGIN MEGOLM SESSION DATA-----\n") + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + sb.WriteString(encoded[i:end]) + sb.WriteByte('\n') + } + sb.WriteString("-----END MEGOLM SESSION DATA-----\n") + return []byte(sb.String()), nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// Room list +// ───────────────────────────────────────────────────────────────────────────── + +type roomEntry struct { + RoomID string `json:"room_id"` + Name string `json:"name"` + Type string `json:"type"` + Aliases []string `json:"aliases"` + CanonicalAlias string `json:"canonical_alias,omitempty"` + MemberCount int `json:"member_count"` + Encrypted bool `json:"encrypted"` +} + +func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) error { + dmRooms := getDMRooms(syncResp) + + var rooms []roomEntry + for roomID, joinedRoom := range syncResp.Rooms.Join { + var name, canonicalAlias string + var aliases []string + encrypted := false + rtype := "normal" + if dmRooms[roomID] { + rtype = "dm" + } + for _, ev := range joinedRoom.State.Events { + switch ev.Type { + case event.StateRoomName: + if c := ev.Content.AsName(); c != nil { + name = c.Name + } + case event.StateCanonicalAlias: + if c := ev.Content.AsCanonicalAlias(); c != nil { + canonicalAlias = string(c.Alias) + for _, a := range c.AltAliases { + aliases = append(aliases, string(a)) + } + } + case event.StateEncryption: + encrypted = true + case event.StateCreate: + if ev.Content.VeryRaw != nil { + var createContent struct { + Type string `json:"type"` + } + if json.Unmarshal(ev.Content.VeryRaw, &createContent) == nil && createContent.Type == "m.space" { + rtype = "space" + } + } + } + } + if name == "" { + name = string(roomID) + } + rooms = append(rooms, roomEntry{ + RoomID: string(roomID), + Name: name, + Type: rtype, + Aliases: aliases, + CanonicalAlias: canonicalAlias, + MemberCount: joinedRoom.Summary.JoinedMemberCount, + Encrypted: encrypted, + }) + slog.Info("Room", "type", rtype, "name", name) + } + + data, err := json.MarshalIndent(rooms, "", " ") + if err != nil { + return err + } + if err := s3Put(ctx, prefix+"/rooms-"+dateStr+".json", data, "application/json"); err != nil { + return err + } + if err := s3Put(ctx, prefix+"/rooms-latest.json", data, "application/json"); err != nil { + return err + } + slog.Info("Uploaded room list", "rooms", len(rooms)) + return nil +} + +func getDMRooms(syncResp *mautrix.RespSync) map[id.RoomID]bool { + dmRooms := make(map[id.RoomID]bool) + for _, ev := range syncResp.AccountData.Events { + if ev.Type == event.AccountDataDirectChats { + // DirectChatsEventContent is map[id.UserID][]id.RoomID + var direct event.DirectChatsEventContent + if err := json.Unmarshal(ev.Content.VeryRaw, &direct); err != nil { + continue + } + for _, roomIDs := range direct { + for _, rid := range roomIDs { + dmRooms[rid] = true + } + } + break + } + } + return dmRooms +} + +// ───────────────────────────────────────────────────────────────────────────── +// Media download +// ───────────────────────────────────────────────────────────────────────────── + +func downloadAndStoreMedia(ctx context.Context, client *mautrix.Client, mxcURL, prefix, label string) error { + if !strings.HasPrefix(mxcURL, "mxc://") { + return nil + } + rest := mxcURL[len("mxc://"):] + slashIdx := strings.Index(rest, "/") + if slashIdx < 0 { + return nil + } + server := rest[:slashIdx] + mediaID := rest[slashIdx+1:] + if server == "" || mediaID == "" { + return nil + } + + s3Key := prefix + "/" + label + "/" + server + "/" + mediaID + if s3Exists(ctx, s3Key) { + return nil + } + + base := strings.TrimRight(client.HomeserverURL.String(), "/") + downloadURL := base + "/_matrix/media/v3/download/" + + url.PathEscape(server) + "/" + url.PathEscape(mediaID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+client.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("media download HTTP %d for %s", resp.StatusCode, mxcURL) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + ct := resp.Header.Get("Content-Type") + if ct == "" { + ct = "application/octet-stream" + } + + finalKey := s3Key + if ext := extFromContentType(ct); ext != "" { + finalKey = s3Key + ext + } + if err := s3Put(ctx, finalKey, data, ct); err != nil { + return err + } + slog.Info("Stored media", "key", finalKey, "bytes", len(data)) + return nil +} + +func extFromContentType(ct string) string { + if i := strings.Index(ct, ";"); i >= 0 { + ct = ct[:i] + } + switch strings.TrimSpace(ct) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "video/mp4": + return ".mp4" + case "video/webm": + return ".webm" + case "audio/mpeg": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "audio/opus": + return ".opus" + case "application/pdf": + return ".pdf" + } + return "" +} + +// ───────────────────────────────────────────────────────────────────────────── +// Event processing +// ───────────────────────────────────────────────────────────────────────────── + +var mediaMsgTypes = map[string]bool{ + "m.image": true, + "m.file": true, + "m.video": true, + "m.audio": true, +} + +// processEvent handles media downloads and profile-update tracking for one event. +// Encrypted events are stored as-is but media/profiles are skipped (no OlmMachine). +func processEvent(ctx context.Context, client *mautrix.Client, ev *event.Event, roomID id.RoomID, prefix string, isDM bool) { + if ev.Type == event.EventEncrypted { + return + } + + if ev.Type == event.EventMessage { + var content struct { + MsgType string `json:"msgtype"` + URL string `json:"url"` + } + if err := json.Unmarshal(ev.Content.VeryRaw, &content); err == nil { + if mediaMsgTypes[content.MsgType] && content.URL != "" { + if isDM || strings.HasSuffix(string(ev.Sender), ":"+ourServerName) { + if err := downloadAndStoreMedia(ctx, client, content.URL, prefix, "media"); err != nil { + slog.Warn("Media download failed", "url", content.URL, "error", err) + } + } + } + } + } + + if ev.Type == event.EventSticker { + var content struct{ URL string `json:"url"` } + if err := json.Unmarshal(ev.Content.VeryRaw, &content); err == nil && content.URL != "" { + if isDM || strings.HasSuffix(string(ev.Sender), ":"+ourServerName) { + if err := downloadAndStoreMedia(ctx, client, content.URL, prefix, "media"); err != nil { + slog.Warn("Sticker download failed", "url", content.URL, "error", err) + } + } + } + } + + if ev.Type == event.StateMember { + recordProfileUpdate(ctx, ev, roomID, prefix) + } +} + +type profileUpdateRecord struct { + TS int64 `json:"ts"` + EventID string `json:"event_id"` + UserID string `json:"user_id"` + RoomID string `json:"room_id"` + DisplayNameOld string `json:"displayname_old,omitempty"` + DisplayNameNew string `json:"displayname_new,omitempty"` + AvatarOld string `json:"avatar_old,omitempty"` + AvatarNew string `json:"avatar_new,omitempty"` +} + +func recordProfileUpdate(ctx context.Context, ev *event.Event, roomID id.RoomID, prefix string) { + stateKey := string(ev.StateKey) + if !strings.HasSuffix(stateKey, ":"+ourServerName) { + return + } + var content, prevContent struct { + Displayname string `json:"displayname"` + AvatarURL string `json:"avatar_url"` + } + if err := json.Unmarshal(ev.Content.VeryRaw, &content); err != nil { + return + } + if ev.Unsigned.PrevContent != nil { + _ = json.Unmarshal(ev.Unsigned.PrevContent.VeryRaw, &prevContent) + } + if content.Displayname == prevContent.Displayname && content.AvatarURL == prevContent.AvatarURL { + return + } + rec := profileUpdateRecord{ + TS: ev.Timestamp, + EventID: string(ev.ID), + UserID: stateKey, + RoomID: string(roomID), + } + if content.Displayname != prevContent.Displayname { + rec.DisplayNameOld = prevContent.Displayname + rec.DisplayNameNew = content.Displayname + } + if content.AvatarURL != prevContent.AvatarURL { + rec.AvatarOld = prevContent.AvatarURL + rec.AvatarNew = content.AvatarURL + if content.AvatarURL != "" { + if err := downloadAndStoreMedia(ctx, client, content.AvatarURL, prefix, "avatars"); err != nil { + slog.Warn("Avatar download failed", "url", content.AvatarURL, "error", err) + } + } + } + line, err := json.Marshal(rec) + if err != nil { + return + } + s3Key := prefix + "/profile-updates.jsonl" + existing, _ := s3Get(ctx, s3Key) + _ = s3Put(ctx, s3Key, append(existing, append(line, '\n')...), "application/x-ndjson") +} + +// ───────────────────────────────────────────────────────────────────────────── +// History pagination +// ───────────────────────────────────────────────────────────────────────────── + +type historyEvent struct { + EventID string `json:"event_id"` + Sender string `json:"sender"` + Type string `json:"type"` + Timestamp int64 `json:"origin_server_ts"` + Content json.RawMessage `json:"content"` +} + +func eventToRecord(ev *event.Event) historyEvent { + return historyEvent{ + EventID: string(ev.ID), + Sender: string(ev.Sender), + Type: ev.Type.Type, + Timestamp: ev.Timestamp, + Content: ev.Content.VeryRaw, + } +} + +func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, prefix string, isDM bool, prevBatch string) error { + safeKey := strings.NewReplacer("/", "_", ":", "_").Replace(string(roomID)) + cursorKey := prefix + "/history-cursor/" + safeKey + ".json" + + var cursor struct { + Token string `json:"token"` + } + if err := s3GetJSON(ctx, cursorKey, &cursor); err != nil { + return err + } + + if cursor.Token == "" { + if prevBatch == "" { + return nil + } + if isDM { + slog.Info("DM first-run: fetching full history", "room_id", roomID) + var messages []historyEvent + token := prevBatch + for { + resp, err := client.Messages(ctx, roomID, token, "", mautrix.DirectionBackward, nil, 100) + if err != nil { + slog.Warn("room_messages error (backward)", "room_id", roomID, "error", err) + break + } + if len(resp.Chunk) == 0 { + break + } + for _, ev := range resp.Chunk { + messages = append(messages, eventToRecord(ev)) + processEvent(ctx, client, ev, roomID, prefix, true) + } + if resp.End == "" || resp.End == token { + break + } + token = resp.End + } + if len(messages) > 0 { + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + histKey := prefix + "/history/" + safeKey + "/" + dateStr + ".jsonl" + var buf bytes.Buffer + for _, m := range messages { + line, _ := json.Marshal(m) + buf.Write(line) + buf.WriteByte('\n') + } + _ = s3Put(ctx, histKey, buf.Bytes(), "application/x-ndjson") + slog.Info("DM history stored", "room_id", roomID, "events", len(messages)) + } + } + cursorJSON, _ := json.Marshal(map[string]string{"token": prevBatch}) + _ = s3Put(ctx, cursorKey, cursorJSON, "application/json") + return nil + } + + var messages []historyEvent + nextToken := cursor.Token + histKey := prefix + "/history/" + safeKey + "/" + dateStr + ".jsonl" + + for range 100 { + resp, err := client.Messages(ctx, roomID, nextToken, "", mautrix.DirectionForward, nil, 100) + if err != nil { + slog.Warn("room_messages error (forward)", "room_id", roomID, "error", err) + break + } + if len(resp.Chunk) == 0 { + break + } + for _, ev := range resp.Chunk { + messages = append(messages, eventToRecord(ev)) + processEvent(ctx, client, ev, roomID, prefix, isDM) + } + if resp.End == "" || resp.End == nextToken { + nextToken = "" + break + } + nextToken = resp.End + } + + if len(messages) > 0 { + existing, _ := s3Get(ctx, histKey) + var buf bytes.Buffer + buf.Write(existing) + for _, m := range messages { + line, _ := json.Marshal(m) + buf.Write(line) + buf.WriteByte('\n') + } + _ = s3Put(ctx, histKey, buf.Bytes(), "application/x-ndjson") + slog.Info("History updated", "room_id", roomID, "new_events", len(messages)) + } + + if nextToken != "" && nextToken != cursor.Token { + cursorJSON, _ := json.Marshal(map[string]string{"token": nextToken}) + _ = s3Put(ctx, cursorKey, cursorJSON, "application/json") + } + return nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-account backup +// ───────────────────────────────────────────────────────────────────────────── + +func backupAccount(ctx context.Context, acc accountCfg) error { + slog.Info("=== Backing up account ===", "user_id", acc.UserID) + + storeS3Key := acc.Prefix + "/crypto-store.tar.gz" + sessionS3Key := acc.Prefix + "/session.json" + syncTokenS3Key := acc.Prefix + "/sync-token.json" + + if err := downloadStore(ctx, acc.StoreDir, storeS3Key); err != nil { + slog.Warn("Could not restore store", "error", err) + } + + client, err := mautrix.NewClient(homeserver, "", "") + if err != nil { + return fmt.Errorf("create client: %w", err) + } + + if _, err := ensureSession(ctx, client, acc, sessionS3Key); err != nil { + return fmt.Errorf("session: %w", err) + } + + var syncToken struct { + NextBatch string `json:"next_batch"` + } + _ = s3GetJSON(ctx, syncTokenS3Key, &syncToken) + + slog.Info("Syncing", "since", syncToken.NextBatch) + syncResp, err := client.SyncRequest(ctx, 60000, syncToken.NextBatch, "", true, event.PresenceUnavailable) + if err != nil { + return fmt.Errorf("sync: %w", err) + } + if data, err := json.Marshal(map[string]string{"next_batch": syncResp.NextBatch}); err == nil { + _ = s3Put(ctx, syncTokenS3Key, data, "application/json") + } + slog.Info("Sync done", "rooms", len(syncResp.Rooms.Join)) + + if acc.SSSSKey != "" { + if err := fetchAndExportKeyBackup(ctx, client, acc.SSSSKey, acc.Prefix); err != nil { + slog.Warn("Key backup export failed", "error", err) + } + } + + if err := saveRoomList(ctx, client, syncResp, acc.Prefix); err != nil { + slog.Warn("Room list failed", "error", err) + } + + dmRooms := getDMRooms(syncResp) + for roomID, joinedRoom := range syncResp.Rooms.Join { + isDM := dmRooms[roomID] + if err := paginateRoom(ctx, client, roomID, acc.Prefix, isDM, joinedRoom.Timeline.PrevBatch); err != nil { + slog.Warn("History error", "room_id", roomID, "error", err) + } + } + + if err := uploadStore(ctx, acc.StoreDir, storeS3Key); err != nil { + slog.Warn("Could not upload store", "error", err) + } + + slog.Info("Done", "user_id", acc.UserID) + return nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// main +// ───────────────────────────────────────────────────────────────────────────── + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + ctx := context.Background() + + if err := initS3(ctx); err != nil { + slog.Error("Failed to init S3", "error", err) + os.Exit(1) + } + + for _, acc := range accounts { + if err := backupAccount(ctx, acc); err != nil { + slog.Error("Account backup failed", "user_id", acc.UserID, "error", err) + } + } + + slog.Info("All accounts backed up successfully") +} diff --git a/apps/talos_cluster/matrix-backup/kustomization.yaml b/apps/talos_cluster/matrix-backup/kustomization.yaml @@ -4,5 +4,4 @@ namespace: matrix-backup resources: - namespace.yaml - secret.yaml - - backup-script-configmap.yaml - backup-cronjob.yaml