cluster

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

commit 51e3819c205eb9ea79421f82e00fc70dd21bb2e1
parent 176e95a8855422c7e2c42275abdf6f1ed5068848
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Sat,  4 Apr 2026 15:11:21 +0200

various improvements across the board

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

Diffstat:
Mapps/talos_cluster/matrix-backup/backup-cronjob.yaml | 1+
Mapps/talos_cluster/matrix-backup/backup-tool/.gitignore | 2+-
Aapps/talos_cluster/matrix-backup/backup-tool/accountdata.go | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/backup.go | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/config.go | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/events.go | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/history.go | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/keys.go | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapps/talos_cluster/matrix-backup/backup-tool/main.go | 1743-------------------------------------------------------------------------------
Aapps/talos_cluster/matrix-backup/backup-tool/matrix.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/media.go | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/rooms.go | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/s3.go | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/session.go | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/matrix-backup/backup-tool/store.go | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 2013 insertions(+), 1744 deletions(-)

diff --git a/apps/talos_cluster/matrix-backup/backup-cronjob.yaml b/apps/talos_cluster/matrix-backup/backup-cronjob.yaml @@ -37,6 +37,7 @@ spec: cpu: 100m memory: 128Mi limits: + cpu: 500m memory: 512Mi env: - name: HOMESERVER diff --git a/apps/talos_cluster/matrix-backup/backup-tool/.gitignore b/apps/talos_cluster/matrix-backup/backup-tool/.gitignore @@ -1,2 +1,2 @@ -decrypt/ +recovery/ decrypted/ diff --git a/apps/talos_cluster/matrix-backup/backup-tool/accountdata.go b/apps/talos_cluster/matrix-backup/backup-tool/accountdata.go @@ -0,0 +1,154 @@ +// accountdata.go — backup of global account data, per-room account data, and +// the initial profile snapshot. All three use a retry-on-next-run pattern: +// failures are logged as warnings so the next CronJob run retries automatically. +package main + +import ( + "context" + "encoding/json" + "log/slog" + "net/url" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// backupAccountData saves a snapshot of all global account-data events to S3. +// The strategy is overwrite-every-run so the file is always current; a failed +// write leaves the previous version in place and the next run retries. +// +// S3 keys: +// +// {prefix}/account-data-latest.json.age +// {prefix}/account-data-{dateStr}.json.age +func backupAccountData(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) { + accountData := make(map[string]json.RawMessage) + + // Seed from the sync response (present on first sync and when changed). + for _, ev := range syncResp.AccountData.Events { + if ev.Content.VeryRaw != nil { + accountData[ev.Type.Type] = ev.Content.VeryRaw + } + } + + // Always fetch these key types directly from the API so they're present even + // on incremental syncs where account data hasn't changed. + for _, t := range []string{"m.push_rules", "m.ignored_user_list", "m.direct"} { + if _, ok := accountData[t]; !ok { + var raw json.RawMessage + if err := getAccountData(ctx, client, t, &raw); err == nil && raw != nil { + accountData[t] = raw + } + } + } + + if len(accountData) == 0 { + slog.Info("No account data to save", "prefix", prefix) + return + } + + data, err := json.MarshalIndent(accountData, "", " ") + if err != nil { + slog.Warn("Failed to marshal account data", "error", err) + return + } + if err := s3PutAge(ctx, prefix+"/account-data-latest.json", data); err != nil { + slog.Warn("Failed to save account data", "prefix", prefix, "error", err) + return + } + if err := s3PutAge(ctx, prefix+"/account-data-"+dateStr+".json", data); err != nil { + slog.Warn("Failed to save dated account data", "prefix", prefix, "error", err) + } + slog.Info("Account data saved", "prefix", prefix, "types", len(accountData)) +} + +// backupRoomAccountData saves per-room account-data (room tags, read markers, +// etc.) to S3 using a merge pattern so rooms absent from this sync retain their +// last-known values. +// +// S3 key: +// +// {prefix}/room-account-data-latest.json.age +func backupRoomAccountData(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) { + // Load existing data (merge pattern — rooms not in this sync are preserved). + existing := make(map[string]map[string]json.RawMessage) + if raw, err := getDecryptedAgeFromS3(ctx, prefix+"/room-account-data-latest.json.age"); err != nil { + slog.Warn("Could not load previous room account data", "error", err) + } else if raw != nil { + if err := json.Unmarshal(raw, &existing); err != nil { + slog.Warn("Previous room account data is corrupt, starting fresh", "error", err) + } + } + + for roomID, joinedRoom := range syncResp.Rooms.Join { + roomData := existing[string(roomID)] + if roomData == nil { + roomData = make(map[string]json.RawMessage) + } + + // Collect all account-data types present in the sync delta. + for _, ev := range joinedRoom.AccountData.Events { + if ev.Content.VeryRaw != nil { + roomData[ev.Type.Type] = ev.Content.VeryRaw + } + } + + // Always fetch m.room.tag directly so it's present even without a delta. + tagPath := "/_matrix/client/v3/user/" + + url.PathEscape(client.UserID.String()) + + "/rooms/" + + url.PathEscape(string(roomID)) + + "/tags" + var tags json.RawMessage + if err := matrixGetJSON(ctx, client, tagPath, &tags); err == nil && tags != nil { + roomData[event.AccountDataRoomTags.Type] = tags + } + + existing[string(roomID)] = roomData + } + + data, err := json.MarshalIndent(existing, "", " ") + if err != nil { + slog.Warn("Failed to marshal room account data", "error", err) + return + } + if err := s3PutAge(ctx, prefix+"/room-account-data-latest.json", data); err != nil { + slog.Warn("Failed to save room account data", "prefix", prefix, "error", err) + return + } + slog.Info("Room account data saved", "prefix", prefix, "rooms", len(existing)) +} + +// backupProfileSnapshot saves the user's current displayname and avatar_url to +// S3 on the first run. Subsequent runs skip it (write-once semantics); if the +// write failed on a previous run the file is absent and this run will retry. +// +// S3 key: +// +// {prefix}/profile-snapshot.json.age +func backupProfileSnapshot(ctx context.Context, client *mautrix.Client, prefix string) { + snapshotKey := prefix + "/profile-snapshot.json.age" + if s3Exists(ctx, snapshotKey) { + return + } + var profile struct { + Displayname string `json:"displayname"` + AvatarURL string `json:"avatar_url"` + } + path := "/_matrix/client/v3/profile/" + url.PathEscape(client.UserID.String()) + if err := matrixGetJSON(ctx, client, path, &profile); err != nil { + slog.Warn("Failed to fetch profile snapshot", "error", err) + return + } + data, err := json.MarshalIndent(profile, "", " ") + if err != nil { + slog.Warn("Failed to marshal profile snapshot", "error", err) + return + } + // s3PutAge appends ".age" — writes to prefix+"/profile-snapshot.json.age". + if err := s3PutAge(ctx, prefix+"/profile-snapshot.json", data); err != nil { + slog.Warn("Failed to save profile snapshot", "prefix", prefix, "error", err) + return + } + slog.Info("Profile snapshot saved", "prefix", prefix, "displayname", profile.Displayname) +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/backup.go b/apps/talos_cluster/matrix-backup/backup-tool/backup.go @@ -0,0 +1,154 @@ +// backup.go — per-account backup orchestration. +package main + +import ( + "context" + "encoding/json" + "log/slog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// backupAccount runs the full backup pipeline for a single Matrix account: +// restore crypto state, sync, export E2EE keys, save room list, paginate +// history, save account data, then persist state back to S3. +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 err + } + + if _, err := ensureSession(ctx, client, acc, sessionS3Key); err != nil { + return 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 err + } + if data, err := json.Marshal(map[string]string{"next_batch": syncResp.NextBatch}); err == nil { + if err := s3Put(ctx, syncTokenS3Key, data, "application/json"); err != nil { + slog.Warn("Failed to save sync token", "error", err) + } + } + slog.Info("Sync done", "rooms", len(syncResp.Rooms.Join)) + + var sessions megolmSessions + if acc.SSSSKey != "" { + sessions, err = fetchAndExportKeyBackup(ctx, client, acc.SSSSKey, acc.Prefix) + if 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) + } + + // Back up account-data, per-room account data, and profile snapshot. + backupAccountData(ctx, client, syncResp, acc.Prefix) + backupRoomAccountData(ctx, client, syncResp, acc.Prefix) + backupProfileSnapshot(ctx, client, acc.Prefix) + + // Build the DM room set: m.direct API + 2-member heuristic from stored list. + dmRooms := getDMRooms(ctx, client, syncResp) + if raw, err := getDecryptedAgeFromS3(ctx, acc.Prefix+"/rooms-latest.json.age"); err == nil && raw != nil { + var storedRooms []roomEntry + if json.Unmarshal(raw, &storedRooms) == nil { + for _, r := range storedRooms { + if r.Type == "dm" { + dmRooms[id.RoomID(r.RoomID)] = true + } + } + } + } + + // Paginate history for all rooms present in the sync delta. + for roomID, joinedRoom := range syncResp.Rooms.Join { + isDM := dmRooms[roomID] + if err := paginateRoom(ctx, client, roomID, acc.Prefix, isDM, joinedRoom.Timeline.PrevBatch, syncResp.NextBatch, sessions); err != nil { + slog.Warn("History error", "room_id", roomID, "error", err) + } + } + + // Catch-up: DM rooms absent from this sync delta (no new events) may still + // need their history backfilled. Use the stored rooms list as the authoritative + // DM source, falling back to the API-based dmRooms map. + catchupDMs := make(map[id.RoomID]bool) + for roomID := range dmRooms { + catchupDMs[roomID] = true + } + if raw, err := getDecryptedAgeFromS3(ctx, acc.Prefix+"/rooms-latest.json.age"); err != nil { + slog.Warn("DM catch-up: failed to read rooms list", "error", err) + } else if raw == nil { + slog.Warn("DM catch-up: rooms list not found or ageIdentity not set") + } else { + var storedRooms []roomEntry + if err := json.Unmarshal(raw, &storedRooms); err != nil { + slog.Warn("DM catch-up: failed to parse rooms list", "error", err) + } else { + slog.Info("DM catch-up: loaded rooms list", "total_rooms", len(storedRooms)) + for _, r := range storedRooms { + if r.Type == "dm" { + catchupDMs[id.RoomID(r.RoomID)] = true + } + } + } + } + slog.Info("DM catch-up: checking rooms", "total_dm_rooms", len(catchupDMs), "in_sync", len(syncResp.Rooms.Join)) + for roomID := range catchupDMs { + if _, inSync := syncResp.Rooms.Join[roomID]; inSync { + continue // already handled above + } + safeKey := roomSafeKey(roomID) + dmHistoryDoneKey := acc.Prefix + "/history-cursor/" + safeKey + ".dm-done" + dmDone, dmDoneErr := s3Get(ctx, dmHistoryDoneKey) + if dmDoneErr != nil { + slog.Warn("DM catch-up: error checking dm-done marker", "room_id", roomID, "error", dmDoneErr) + } + if dmDone != nil { + continue // already backfilled + } + cursorKey := acc.Prefix + "/history-cursor/" + safeKey + ".json" + var cursor struct { + Token string `json:"token"` + } + if err := s3GetJSON(ctx, cursorKey, &cursor); err != nil { + slog.Warn("DM catch-up: error reading cursor", "room_id", roomID, "error", err) + continue + } + if cursor.Token == "" { + slog.Info("DM catch-up: no cursor yet, skipping", "room_id", roomID) + continue + } + slog.Info("DM catch-up: backfilling room", "room_id", roomID, "cursor", cursor.Token) + if err := paginateRoom(ctx, client, roomID, acc.Prefix, true, cursor.Token, syncResp.NextBatch, sessions); err != nil { + slog.Warn("DM catch-up 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 +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/config.go b/apps/talos_cluster/matrix-backup/backup-tool/config.go @@ -0,0 +1,114 @@ +// config.go — global configuration, account definitions, and age key initialisation. +package main + +import ( + "fmt" + "log/slog" + "os" + "strings" + "time" + + "filippo.io/age" + "maunium.net/go/mautrix/id" +) + +// ourServerName is used to decide whose media and avatars to download. +// Override with S3_SERVER_NAME if needed. +var ourServerName = envOrDefault("SERVER_NAME", "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") + s3Region = envOrDefault("S3_REGION", "hel1") + keyExportPass = mustEnv("KEY_EXPORT_PASSPHRASE") + dateStr = time.Now().UTC().Format("20060102") + runStr = time.Now().UTC().Format("20060102-150405") + + 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", + }, + } + + // ageRecipients holds the parsed public keys used to encrypt history files. + ageRecipients []age.Recipient + + // ageIdentity is the optional private key used to decrypt files written by + // this tool (loaded from AGE_PRIVATE_KEY). Allows reading back previously + // encrypted objects without storing plain copies in S3. + ageIdentity age.Identity +) + +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 +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// initAgeRecipients parses a comma-separated list of age public keys. +func initAgeRecipients(s string) error { + for _, raw := range strings.Split(s, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + r, err := age.ParseX25519Recipient(raw) + if err != nil { + return fmt.Errorf("invalid age recipient %q: %w", raw, err) + } + ageRecipients = append(ageRecipients, r) + } + if len(ageRecipients) == 0 { + return fmt.Errorf("AGE_RECIPIENTS is empty — at least one public key is required") + } + return nil +} + +// initAgeIdentity parses an AGE-SECRET-KEY-1… line for decrypting previously +// written .age objects (e.g. the room list, session token). +func initAgeIdentity(privKey string) error { + privKey = strings.TrimSpace(privKey) + if privKey == "" { + return nil + } + ids, err := age.ParseIdentities(strings.NewReader(privKey)) + if err != nil { + return fmt.Errorf("parse age identity: %w", err) + } + if len(ids) > 0 { + ageIdentity = ids[0] + } + return nil +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/events.go b/apps/talos_cluster/matrix-backup/backup-tool/events.go @@ -0,0 +1,167 @@ +// events.go — per-event processing: media download, profile-change recording, +// and the historyEvent record type shared by both pagination paths. +package main + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// historyEvent is the on-disk representation of a single room event stored +// in JSONL files under prefix/history/<safeKey>/<run>.jsonl.age. +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"` + Encrypted bool `json:"encrypted,omitempty"` +} + +func eventToRecord(ev *event.Event, wasEncrypted bool) historyEvent { + return historyEvent{ + EventID: string(ev.ID), + Sender: string(ev.Sender), + Type: ev.Type.Type, + Timestamp: ev.Timestamp, + Content: ev.Content.VeryRaw, + Encrypted: wasEncrypted, + } +} + +// roomSafeKey converts a room ID into a string safe for use as an S3 key +// component by replacing '/' and ':' with '_'. +func roomSafeKey(roomID id.RoomID) string { + return strings.NewReplacer("/", "_", ":", "_").Replace(string(roomID)) +} + +// processEvent downloads media and records profile changes for a single event. +// The event must already be decrypted before this is called. +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"` + File json.RawMessage `json:"file"` + } + if err := json.Unmarshal(ev.Content.VeryRaw, &content); err == nil { + mediaURL := content.URL + if mediaURL == "" && content.File != nil { + var f struct { + URL string `json:"url"` + } + if json.Unmarshal(content.File, &f) == nil { + mediaURL = f.URL + } + } + if mediaMsgTypes[content.MsgType] && mediaURL != "" { + if isDM || strings.HasSuffix(string(ev.Sender), ":"+ourServerName) { + if err := downloadAndStoreMedia(ctx, client, mediaURL, prefix, "media"); err != nil { + slog.Warn("Media download failed", "url", mediaURL, "error", err) + } + } + } + } + } + + if ev.Type == event.EventSticker { + var content struct { + URL string `json:"url"` + File json.RawMessage `json:"file"` + } + if err := json.Unmarshal(ev.Content.VeryRaw, &content); err == nil { + mediaURL := content.URL + if mediaURL == "" && content.File != nil { + var f struct { + URL string `json:"url"` + } + if json.Unmarshal(content.File, &f) == nil { + mediaURL = f.URL + } + } + if mediaURL != "" { + if isDM || strings.HasSuffix(string(ev.Sender), ":"+ourServerName) { + if err := downloadAndStoreMedia(ctx, client, mediaURL, prefix, "media"); err != nil { + slog.Warn("Sticker download failed", "url", mediaURL, "error", err) + } + } + } + } + } + + if ev.Type == event.StateMember { + recordProfileUpdate(ctx, client, 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"` +} + +// recordProfileUpdate appends a JSONL entry to S3 when a member event shows a +// display-name or avatar change for a local user; also downloads the new avatar. +func recordProfileUpdate(ctx context.Context, client *mautrix.Client, ev *event.Event, roomID id.RoomID, prefix string) { + if ev.StateKey == nil { + return + } + stateKey := *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") +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/history.go b/apps/talos_cluster/matrix-backup/backup-tool/history.go @@ -0,0 +1,206 @@ +// history.go — room history pagination: forward incremental sync and DM +// backward backfill. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// paginateRoom fetches room history and writes new events to a per-run +// age-encrypted JSONL file in S3. For DMs on first run the full history is +// fetched backwards from the current position before normal forward pagination. +// +// syncNextBatch is the nextBatch token from the current sync response. On first +// run it is saved as the forward cursor so the next run only sees events that +// arrive after this sync — avoiding re-fetching events already in the sync. +func paginateRoom( + ctx context.Context, + client *mautrix.Client, + roomID id.RoomID, + prefix string, + isDM bool, + prevBatch string, + syncNextBatch string, + sessions megolmSessions, +) error { + safeKey := roomSafeKey(roomID) + cursorKey := prefix + "/history-cursor/" + safeKey + ".json" + dmHistoryDoneKey := prefix + "/history-cursor/" + safeKey + ".dm-done" + + var cursor struct { + Token string `json:"token"` + } + if err := s3GetJSON(ctx, cursorKey, &cursor); err != nil { + return err + } + + // fetchDMHistory does a full backward walk from fromToken and stores results. + // Used on first run for DMs and as catch-up when a room was initially missed. + fetchDMHistory := func(fromToken string) { + slog.Info("DM: fetching full history", "room_id", roomID, "from_token", fromToken) + var messages []historyEvent + token := fromToken + for { + resp, err := client.Messages(ctx, roomID, token, "", mautrix.DirectionBackward, nil, 100) + if err != nil { + slog.Warn("room_messages error (backward)", "room_id", roomID, "token", token, "error", err) + break + } + slog.Info("DM history page", "room_id", roomID, "chunk_size", len(resp.Chunk), "end", resp.End) + if len(resp.Chunk) == 0 { + break + } + for _, ev := range resp.Chunk { + wasEncrypted := ev.Type == event.EventEncrypted + ev = tryDecryptEvent(ev, sessions) + messages = append(messages, eventToRecord(ev, wasEncrypted)) + processEvent(ctx, client, ev, roomID, prefix, true) + } + if resp.End == "" || resp.End == token { + break + } + token = resp.End + } + if len(messages) > 0 { + // Reverse so events are stored oldest-first. + 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 + "/" + runStr + "-backfill.jsonl" + var buf bytes.Buffer + for _, m := range messages { + line, err := json.Marshal(m) + if err != nil { + slog.Warn("Failed to marshal event, skipping", "event_id", m.EventID, "error", err) + continue + } + buf.Write(line) + buf.WriteByte('\n') + } + if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil { + slog.Warn("Failed to upload DM history", "room_id", roomID, "error", err) + } else { + slog.Info("DM history stored", "room_id", roomID, "events", len(messages)) + if err := s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain"); err != nil { + slog.Warn("Failed to write dm-done marker", "room_id", roomID, "error", err) + } + } + } else { + // Nothing to backfill — mark done so we don't retry every run. + slog.Info("DM history: no messages found, marking done", "room_id", roomID) + if err := s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain"); err != nil { + slog.Warn("Failed to write dm-done marker", "room_id", roomID, "error", err) + } + } + } + + if cursor.Token == "" { + // First time we've seen this room. For DMs, walk all the way back through + // history so we have a complete record from day one. + if isDM && prevBatch != "" { + fetchDMHistory(prevBatch) + } + // Save the sync's nextBatch as the forward cursor so the next run only + // picks up genuinely new events. + if syncNextBatch == "" { + syncNextBatch = prevBatch + } + if syncNextBatch != "" { + cursorJSON, err := json.Marshal(map[string]string{"token": syncNextBatch}) + if err == nil { + if err := s3Put(ctx, cursorKey, cursorJSON, "application/json"); err != nil { + slog.Warn("Failed to save cursor (first run)", "room_id", roomID, "error", err) + } + } + } + return nil + } + + // Incremental run: if this is a DM but we never completed a backward history + // fetch (e.g. room was missed because it wasn't in m.direct on first run), + // do it now using the saved cursor as the backward starting point. + // Note: prevBatch may be empty for rooms with no new events in a full_state + // sync, so we use cursor.Token directly rather than gating on prevBatch. + if isDM { + dmDoneData, _ := s3Get(ctx, dmHistoryDoneKey) + if dmDoneData == nil { + slog.Info("DM catch-up: no history marker found, backfilling", "room_id", roomID) + fetchDMHistory(cursor.Token) + } + } + + // Incremental forward pagination from the saved cursor. + var messages []historyEvent + nextToken := cursor.Token + lastGoodToken := cursor.Token + histKey := prefix + "/history/" + safeKey + "/" + runStr + ".jsonl" + + const maxPages = 100 + pages := 0 + for range maxPages { + pages++ + 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 { + wasEncrypted := ev.Type == event.EventEncrypted + ev = tryDecryptEvent(ev, sessions) + messages = append(messages, eventToRecord(ev, wasEncrypted)) + processEvent(ctx, client, ev, roomID, prefix, isDM) + } + if resp.End == "" || resp.End == nextToken { + lastGoodToken = nextToken + nextToken = "" + break + } + lastGoodToken = resp.End + nextToken = resp.End + } + if pages == maxPages && nextToken != "" { + slog.Warn("Pagination cap reached — room has more events; next run will continue", + "room_id", roomID, "pages", maxPages) + lastGoodToken = nextToken + } + + if len(messages) > 0 { + var buf bytes.Buffer + for _, m := range messages { + line, err := json.Marshal(m) + if err != nil { + slog.Warn("Failed to marshal event, skipping", "event_id", m.EventID, "error", err) + continue + } + buf.Write(line) + buf.WriteByte('\n') + } + if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil { + slog.Warn("Failed to upload history", "room_id", roomID, "error", err) + } + slog.Info("History updated", "room_id", roomID, "new_events", len(messages)) + } + + // Advance the cursor so the next run never re-processes stored events. + if lastGoodToken != cursor.Token { + cursorJSON, err := json.Marshal(map[string]string{"token": lastGoodToken}) + if err != nil { + return fmt.Errorf("marshal cursor: %w", err) + } + if err := s3Put(ctx, cursorKey, cursorJSON, "application/json"); err != nil { + slog.Warn("Failed to advance cursor", "room_id", roomID, "error", err) + } + } + return nil +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/keys.go b/apps/talos_cluster/matrix-backup/backup-tool/keys.go @@ -0,0 +1,327 @@ +// keys.go — SSSS key derivation, Megolm key-backup export, and in-memory +// session decryption used during history pagination. +package main + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "strings" + + "golang.org/x/crypto/pbkdf2" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/backup" + "maunium.net/go/mautrix/crypto/goolm/session" + "maunium.net/go/mautrix/crypto/ssss" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +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 format. +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"` +} + +// megolmSessions maps session IDs to live inbound sessions ready to decrypt. +type megolmSessions map[id.SessionID]*session.MegolmInboundSession + +// ───────────────────────────────────────────────────────────────────────────── +// Key-backup fetch and export +// ───────────────────────────────────────────────────────────────────────────── + +// fetchAndExportKeyBackup derives the Megolm backup key from SSSS, downloads +// all room sessions from the server key backup, decrypts them, uploads a JSON +// session dump and standard key-export file to S3, and returns an in-memory +// session map for use during history pagination. +func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recoveryKeyStr, prefix string) (megolmSessions, error) { + var defaultKey defaultKeyEventContent + if err := getAccountData(ctx, client, "m.secret_storage.default_key", &defaultKey); err != nil { + return nil, fmt.Errorf("get default key: %w", err) + } + if defaultKey.Key == "" { + return nil, fmt.Errorf("m.secret_storage.default_key has no 'key' field") + } + keyID := defaultKey.Key + slog.Info("SSSS default key ID", "key_id", keyID) + + var keyMetadata ssss.KeyMetadata + if err := getAccountData(ctx, client, "m.secret_storage.key."+keyID, &keyMetadata); err != nil { + return nil, fmt.Errorf("get key metadata: %w", err) + } + + // ErrUnverifiableKey means no MAC to check — the derived key is still usable. + sssKey, err := keyMetadata.VerifyRecoveryKey(keyID, recoveryKeyStr) + if err != nil && !errors.Is(err, ssss.ErrUnverifiableKey) { + return nil, fmt.Errorf("verify recovery key: %w", err) + } + slog.Info("Recovery key verified (or unverifiable but accepted)") + + var backupSecret encryptedSecretContent + if err := getAccountData(ctx, client, "m.megolm_backup.v1", &backupSecret); err != nil { + return nil, fmt.Errorf("get backup secret: %w", err) + } + encData, ok := backupSecret.Encrypted[keyID] + if !ok { + return nil, fmt.Errorf("backup secret not encrypted with key %q", keyID) + } + backupKeyRaw, err := sssKey.Decrypt("m.megolm_backup.v1", encData) + if err != nil { + return nil, fmt.Errorf("decrypt backup key from SSSS: %w", err) + } + + privateKeyBytes, err := decodeBackupPrivateKey(backupKeyRaw) + if err != nil { + return nil, fmt.Errorf("decode backup private key: %w", err) + } + megolmKey, err := backup.MegolmBackupKeyFromBytes(privateKeyBytes) + if err != nil { + return nil, fmt.Errorf("create megolm backup key: %w", err) + } + slog.Info("Decoded megolm backup private key from SSSS") + + var backupVersion keyBackupVersionResp + if err := matrixGetJSON(ctx, client, "/_matrix/client/v3/room_keys/version", &backupVersion); err != nil { + return nil, fmt.Errorf("get backup version: %w", err) + } + slog.Info("Key backup version", "version", backupVersion.Version) + + var allRooms keyBackupAllRooms + if err := matrixGetJSON(ctx, client, + "/_matrix/client/v3/room_keys/keys?version="+url.QueryEscape(backupVersion.Version), + &allRooms, + ); err != nil { + return nil, fmt.Errorf("get backup keys: %w", err) + } + slog.Info("Fetched key backup", "rooms", len(allRooms.Rooms)) + + sessions := make(megolmSessions) + var exported []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 + } + if sess, err := session.NewMegolmInboundSessionFromExport([]byte(sessionData.SessionKey)); err == nil { + sessions[id.SessionID(sessionID)] = sess + } else { + slog.Warn("Failed to import Megolm session", "session_id", sessionID, "error", err) + } + exported = append(exported, 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, "in_memory", len(sessions)) + + if len(exported) == 0 { + slog.Warn("No sessions decrypted — skipping key export") + return sessions, nil + } + + if rawJSON, err := json.MarshalIndent(exported, "", " "); err == nil { + _ = s3PutAge(ctx, prefix+"/backup-sessions-latest.json", rawJSON) + _ = s3PutAge(ctx, prefix+"/backup-sessions-"+dateStr+".json", rawJSON) + } + + exportData, err := buildMegolmExport(exported, keyExportPass) + if err != nil { + return sessions, fmt.Errorf("build megolm export: %w", err) + } + if err := s3PutAge(ctx, prefix+"/crypto-keys-latest.bin", exportData); err != nil { + return sessions, err + } + if err := s3PutAge(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData); err != nil { + return sessions, err + } + slog.Info("Exported E2EE keys", "sessions", len(exported), "bytes", len(exportData)) + return sessions, nil +} + +// decodeBackupPrivateKey extracts the raw 32-byte Curve25519 private key. +// Tries several base64 encodings used in the wild before giving up. +func decodeBackupPrivateKey(raw []byte) ([]byte, error) { + if len(raw) == 32 { + return raw, nil + } + s := strings.TrimSpace(string(raw)) + for _, enc := range []*base64.Encoding{ + base64.RawStdEncoding, + base64.StdEncoding, + base64.RawURLEncoding, + base64.URLEncoding, + } { + if b, err := enc.DecodeString(s); err == nil && len(b) == 32 { + return b, nil + } + } + return nil, fmt.Errorf("cannot decode %d-byte value as a 32-byte Curve25519 private key", len(raw)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Event decryption +// ───────────────────────────────────────────────────────────────────────────── + +// tryDecryptEvent attempts in-place Megolm decryption of an m.room.encrypted +// event. Returns the event unchanged if decryption is not possible. +func tryDecryptEvent(ev *event.Event, sessions megolmSessions) *event.Event { + if ev.Type != event.EventEncrypted || sessions == nil { + return ev + } + // Events from client.Messages() only have VeryRaw set; ParseRaw must be + // called before the typed helpers (AsEncrypted etc.) work. + if ev.Content.Parsed == nil { + _ = ev.Content.ParseRaw(ev.Type) + } + content := ev.Content.AsEncrypted() + if content.Algorithm != id.AlgorithmMegolmV1 { + return ev + } + sess, ok := sessions[content.SessionID] + if !ok { + return ev + } + plaintext, _, err := sess.Decrypt(content.MegolmCiphertext) + if err != nil { + slog.Debug("Megolm decrypt failed", "session_id", content.SessionID, "error", err) + return ev + } + var inner struct { + Type string `json:"type"` + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(plaintext, &inner); err != nil { + return ev + } + decrypted := *ev + decrypted.Type = event.NewEventType(inner.Type) + decrypted.Content = event.Content{VeryRaw: inner.Content} + return &decrypted +} + +// ───────────────────────────────────────────────────────────────────────────── +// Megolm key export (spec §14.4 — PBKDF2-SHA512 + AES-256-CTR + HMAC-SHA256) +// ───────────────────────────────────────────────────────────────────────────── + +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 +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/main.go b/apps/talos_cluster/matrix-backup/backup-tool/main.go @@ -11,1754 +11,11 @@ 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" - - "filippo.io/age" - "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/goolm/session" - "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") - runStr = time.Now().UTC().Format("20060102-150405") - - 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", - }, - } - - // ageRecipients holds the parsed public keys used to encrypt history files. - ageRecipients []age.Recipient - - // ageIdentity is the optional private key used to decrypt files written by - // this tool (loaded from AGE_PRIVATE_KEY). Allows reading back previously - // encrypted objects (e.g. the room list) without storing plain copies in S3. - ageIdentity age.Identity ) -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 -} - -// initAgeRecipients parses a comma-separated list of age public keys and -// populates the global ageRecipients slice. -func initAgeRecipients(s string) error { - for _, raw := range strings.Split(s, ",") { - raw = strings.TrimSpace(raw) - if raw == "" { - continue - } - r, err := age.ParseX25519Recipient(raw) - if err != nil { - return fmt.Errorf("invalid age recipient %q: %w", raw, err) - } - ageRecipients = append(ageRecipients, r) - } - if len(ageRecipients) == 0 { - return fmt.Errorf("AGE_RECIPIENTS is empty — at least one public key is required") - } - return nil -} - -// initAgeIdentity parses the AGE_PRIVATE_KEY value (a single AGE-SECRET-KEY-1… -// line) and stores it for use when decrypting previously written .age files. -func initAgeIdentity(privKey string) error { - privKey = strings.TrimSpace(privKey) - if privKey == "" { - return nil - } - ids, err := age.ParseIdentities(strings.NewReader(privKey)) - if err != nil { - return fmt.Errorf("parse age identity: %w", err) - } - if len(ids) > 0 { - ageIdentity = ids[0] - } - return nil -} - -// ───────────────────────────────────────────────────────────────────────────── -// Age encryption / decryption -// ───────────────────────────────────────────────────────────────────────────── - -// ageEncrypt encrypts data for all ageRecipients and returns the ciphertext. -func ageEncrypt(data []byte) ([]byte, error) { - var buf bytes.Buffer - w, err := age.Encrypt(&buf, ageRecipients...) - if err != nil { - return nil, err - } - if _, err := w.Write(data); err != nil { - return nil, err - } - if err := w.Close(); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -// getDecryptedAgeFromS3 fetches an .age object from S3 and decrypts it using -// the configured ageIdentity. Returns (nil, nil) when the object doesn't exist -// or no identity is available. -func getDecryptedAgeFromS3(ctx context.Context, key string) ([]byte, error) { - if ageIdentity == nil { - return nil, nil - } - enc, err := s3Get(ctx, key) - if err != nil || enc == nil { - return nil, err - } - r, err := age.Decrypt(bytes.NewReader(enc), ageIdentity) - if err != nil { - return nil, fmt.Errorf("age decrypt %s: %w", key, err) - } - return io.ReadAll(r) -} - -// s3PutAge age-encrypts data then uploads it under key (appending ".age"). -func s3PutAge(ctx context.Context, key string, data []byte) error { - enc, err := ageEncrypt(data) - if err != nil { - return fmt.Errorf("age encrypt %s: %w", key, err) - } - return s3Put(ctx, key+".age", enc, "application/octet-stream") -} - -// ───────────────────────────────────────────────────────────────────────────── -// 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, "", - )), - ) - if err != nil { - return err - } - endpoint := "https://" + s3Endpoint - s3c = s3.NewFromConfig(cfg, func(o *s3.Options) { - o.UsePathStyle = true - o.BaseEndpoint = &endpoint - }) - 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 -} - -// s3DeletePrefix deletes all objects whose key starts with prefix. -func s3DeletePrefix(ctx context.Context, prefix string) error { - paginator := s3.NewListObjectsV2Paginator(s3c, &s3.ListObjectsV2Input{ - Bucket: aws.String(s3BucketName), - Prefix: aws.String(prefix), - }) - deleted := 0 - for paginator.HasMorePages() { - page, err := paginator.NextPage(ctx) - if err != nil { - return err - } - for _, obj := range page.Contents { - if _, err := s3c.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(s3BucketName), - Key: obj.Key, - }); err != nil { - slog.Warn("Failed to delete S3 object", "key", *obj.Key, "error", err) - } else { - deleted++ - } - } - } - slog.Info("Deleted S3 prefix", "prefix", prefix, "count", deleted) - return nil -} - -// ───────────────────────────────────────────────────────────────────────────── -// Crypto-store tarball (persists sessions and sync tokens across CronJob runs) -// ───────────────────────────────────────────────────────────────────────────── - -// downloadStore restores a previously uploaded crypto store tarball from S3. -// Missing store (first run) is not an error — the caller starts fresh. -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 -} - -// uploadStore tarballs the crypto store directory and uploads it to S3 for the -// next run to restore. -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"` -} - -// ensureSession loads a saved session from S3 or logs in with the account -// password and saves the resulting session for future runs. -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, - InitialDeviceDisplayName: "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 -// ───────────────────────────────────────────────────────────────────────────── - -// matrixGetJSON makes an authenticated GET to the homeserver and JSON-decodes -// the response. Used for endpoints not wrapped by the mautrix client. -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"` -} - -// megolmSessions maps session IDs to live inbound sessions ready to decrypt. -type megolmSessions map[id.SessionID]*session.MegolmInboundSession - -// fetchAndExportKeyBackup derives the Megolm backup private key from SSSS, -// decrypts every backed-up session, and uploads both a raw JSON archive and a -// standard .key export file (importable by any Matrix client) to S3. -// It also returns an in-memory session map for use when decrypting room history. -func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recoveryKeyStr, prefix string) (megolmSessions, error) { - var defaultKey defaultKeyEventContent - if err := getAccountData(ctx, client, "m.secret_storage.default_key", &defaultKey); err != nil { - return nil, fmt.Errorf("get default key: %w", err) - } - if defaultKey.Key == "" { - return nil, fmt.Errorf("m.secret_storage.default_key has no 'key' field") - } - keyID := defaultKey.Key - slog.Info("SSSS default key ID", "key_id", keyID) - - var keyMetadata ssss.KeyMetadata - if err := getAccountData(ctx, client, "m.secret_storage.key."+keyID, &keyMetadata); err != nil { - return nil, fmt.Errorf("get key metadata: %w", err) - } - - // ErrUnverifiableKey just means the metadata has no MAC to check against — - // the derived key is still usable. - sssKey, err := keyMetadata.VerifyRecoveryKey(keyID, recoveryKeyStr) - if err != nil && !errors.Is(err, ssss.ErrUnverifiableKey) { - return nil, fmt.Errorf("verify recovery key: %w", err) - } - slog.Info("Recovery key verified (or unverifiable but accepted)") - - var backupSecret encryptedSecretContent - if err := getAccountData(ctx, client, "m.megolm_backup.v1", &backupSecret); err != nil { - return nil, fmt.Errorf("get backup secret: %w", err) - } - encData, ok := backupSecret.Encrypted[keyID] - if !ok { - return nil, fmt.Errorf("backup secret not encrypted with key %q", keyID) - } - backupKeyRaw, err := sssKey.Decrypt("m.megolm_backup.v1", encData) - if err != nil { - return nil, fmt.Errorf("decrypt backup key from SSSS: %w", err) - } - - privateKeyBytes, err := decodeBackupPrivateKey(backupKeyRaw) - if err != nil { - return nil, fmt.Errorf("decode backup private key: %w", err) - } - megolmKey, err := backup.MegolmBackupKeyFromBytes(privateKeyBytes) - if err != nil { - return nil, fmt.Errorf("create megolm backup key: %w", err) - } - slog.Info("Decoded megolm backup private key from SSSS") - - var backupVersion keyBackupVersionResp - if err := matrixGetJSON(ctx, client, "/_matrix/client/v3/room_keys/version", &backupVersion); err != nil { - return nil, fmt.Errorf("get backup version: %w", err) - } - slog.Info("Key backup version", "version", backupVersion.Version) - - var allRooms keyBackupAllRooms - if err := matrixGetJSON(ctx, client, - "/_matrix/client/v3/room_keys/keys?version="+url.QueryEscape(backupVersion.Version), - &allRooms, - ); err != nil { - return nil, fmt.Errorf("get backup keys: %w", err) - } - slog.Info("Fetched key backup", "rooms", len(allRooms.Rooms)) - - // Decrypt each session: build both the export list and the live session map. - sessions := make(megolmSessions) - var exported []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 - } - - // Build the in-memory session for history decryption. - if sess, err := session.NewMegolmInboundSessionFromExport([]byte(sessionData.SessionKey)); err == nil { - sessions[id.SessionID(sessionID)] = sess - } else { - slog.Warn("Failed to import Megolm session", "session_id", sessionID, "error", err) - } - - exported = append(exported, 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, "in_memory", len(sessions)) - - if len(exported) == 0 { - slog.Warn("No sessions decrypted — skipping key export") - return sessions, nil - } - - if rawJSON, err := json.MarshalIndent(exported, "", " "); err == nil { - _ = s3PutAge(ctx, prefix+"/backup-sessions-latest.json", rawJSON) - _ = s3PutAge(ctx, prefix+"/backup-sessions-"+dateStr+".json", rawJSON) - } - - exportData, err := buildMegolmExport(exported, keyExportPass) - if err != nil { - return sessions, fmt.Errorf("build megolm export: %w", err) - } - if err := s3PutAge(ctx, prefix+"/crypto-keys-latest.bin", exportData); err != nil { - return sessions, err - } - if err := s3PutAge(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData); err != nil { - return sessions, err - } - slog.Info("Exported E2EE keys", "sessions", len(exported), "bytes", len(exportData)) - return sessions, nil -} - -// decodeBackupPrivateKey extracts the raw 32-byte Curve25519 private key from -// whatever format the client stored it as. The spec says unpadded base64, but -// in practice standard, URL-safe, and padded variants all appear in the wild. -// If the bytes are already 32 bytes long they're used directly. -func decodeBackupPrivateKey(raw []byte) ([]byte, error) { - if len(raw) == 32 { - return raw, nil - } - s := strings.TrimSpace(string(raw)) - for _, enc := range []*base64.Encoding{ - base64.RawStdEncoding, - base64.StdEncoding, - base64.RawURLEncoding, - base64.URLEncoding, - } { - if b, err := enc.DecodeString(s); err == nil && len(b) == 32 { - return b, nil - } - } - return nil, fmt.Errorf("cannot decode %d-byte value as a 32-byte Curve25519 private key", len(raw)) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Megolm event decryption -// ───────────────────────────────────────────────────────────────────────────── - -// tryDecryptEvent attempts to decrypt an m.room.encrypted event using the -// in-memory session map. Returns the original event unchanged if decryption -// isn't possible (missing session, wrong algorithm, parse error). -func tryDecryptEvent(ev *event.Event, sessions megolmSessions) *event.Event { - if ev.Type != event.EventEncrypted || sessions == nil { - return ev - } - // Events from client.Messages() only have VeryRaw set; Parsed is nil until - // ParseRaw is called explicitly. AsEncrypted() returns an empty struct (not - // nil) when Parsed is absent, so Algorithm would be "" and we'd bail early. - if ev.Content.Parsed == nil { - _ = ev.Content.ParseRaw(ev.Type) - } - content := ev.Content.AsEncrypted() - if content.Algorithm != id.AlgorithmMegolmV1 { - return ev - } - sess, ok := sessions[content.SessionID] - if !ok { - return ev - } - plaintext, _, err := sess.Decrypt(content.MegolmCiphertext) - if err != nil { - slog.Debug("Megolm decrypt failed", "session_id", content.SessionID, "error", err) - return ev - } - var inner struct { - Type string `json:"type"` - Content json.RawMessage `json:"content"` - } - if err := json.Unmarshal(plaintext, &inner); err != nil { - return ev - } - decrypted := *ev - decrypted.Type = event.NewEventType(inner.Type) - decrypted.Content = event.Content{VeryRaw: inner.Content} - return &decrypted -} - -// ───────────────────────────────────────────────────────────────────────────── -// Megolm key export (spec: https://spec.matrix.org/v1.9/client-server-api/#key-exports) -// ───────────────────────────────────────────────────────────────────────────── - -// buildMegolmExport encodes sessions into the standard passphrase-protected key -// export format used by all Matrix clients (PBKDF2-SHA512 + AES-256-CTR + HMAC-SHA256). -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 -// ───────────────────────────────────────────────────────────────────────────── - -// calcRoomName implements the Matrix room display name algorithm from the spec: -// https://spec.matrix.org/v1.11/client-server-api/#calculating-the-display-name-for-a-room -// -// Priority: explicit m.room.name → canonical alias → heroes list → "Empty Room". -func calcRoomName( - stateEvents []*event.Event, - heroes []id.UserID, - joinedCount, invitedCount int, - selfUserID id.UserID, -) string { - // 1. Explicit room name. - for _, ev := range stateEvents { - if ev.Type == event.StateRoomName { - if c := ev.Content.AsRoomName(); c != nil && c.Name != "" { - return c.Name - } - } - } - - // 2. Canonical alias. - for _, ev := range stateEvents { - if ev.Type == event.StateCanonicalAlias { - if c := ev.Content.AsCanonicalAlias(); c != nil && c.Alias != "" { - return string(c.Alias) - } - } - } - - // 3. Heroes list — build display names from member state events. - memberNames := make(map[id.UserID]string) - for _, ev := range stateEvents { - if ev.Type == event.StateMember && ev.StateKey != nil { - uid := id.UserID(*ev.StateKey) - if c := ev.Content.AsMember(); c != nil && c.Displayname != "" { - memberNames[uid] = c.Displayname - } else { - local, _, _ := uid.Parse() - if local != "" { - memberNames[uid] = local - } else { - memberNames[uid] = string(uid) - } - } - } - } - - // Spec says heroes should already exclude self, but filter just in case. - var filtered []id.UserID - for _, h := range heroes { - if h != selfUserID { - filtered = append(filtered, h) - } - } - - // Build tentative names for each hero, then disambiguate duplicates by - // appending the server part (e.g. "mtrnord (mtrnord.blog)"). - tentativeNames := make(map[id.UserID]string, len(filtered)) - for _, h := range filtered { - if n, ok := memberNames[h]; ok { - tentativeNames[h] = n - } else { - local, _, _ := h.Parse() - if local != "" { - tentativeNames[h] = local - } else { - tentativeNames[h] = string(h) - } - } - } - nameCounts := make(map[string]int, len(filtered)) - for _, n := range tentativeNames { - nameCounts[n]++ - } - heroName := func(uid id.UserID) string { - n := tentativeNames[uid] - if nameCounts[n] > 1 { - _, server, err := uid.Parse() - if err == nil && server != "" { - return n + " (" + server + ")" - } - return string(uid) - } - return n - } - - // Total other members in the room (everyone except self). - others := joinedCount + invitedCount - 1 - if others < 0 { - others = 0 - } - - if len(filtered) == 0 { - if others == 0 { - return "Empty Room" - } - // Heroes list absent but we know there are members — fall through to ID. - return "" - } - - names := make([]string, len(filtered)) - for i, h := range filtered { - names[i] = heroName(h) - } - - // How many members aren't represented by the heroes list. - unnamed := others - len(names) - - if unnamed <= 0 { - // All others are named. - switch len(names) { - case 1: - return names[0] - case 2: - return names[0] + " and " + names[1] - default: - return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] - } - } - - // Some members aren't in the heroes list. - switch len(names) { - case 1: - return fmt.Sprintf("%s and %d others", names[0], unnamed) - case 2: - return fmt.Sprintf("%s, %s, and %d others", names[0], names[1], unnamed) - default: - return fmt.Sprintf("%s, and %d others", strings.Join(names, ", "), unnamed) - } -} - -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"` - ViaServers []string `json:"via_servers,omitempty"` -} - -// collectViaServers returns up to 3 server names suitable for matrix.to -// ?via= parameters. Priority: room-ID server → heroes → joined members from -// state events (heroes are absent from incremental sync summaries). -func collectViaServers(roomID id.RoomID, heroes []id.UserID, stateEvents []*event.Event) []string { - seen := make(map[string]bool) - var servers []string - - add := func(s string) bool { - if s != "" && !seen[s] { - seen[s] = true - servers = append(servers, s) - } - return len(servers) >= 3 - } - - // Server embedded in the room ID (old-style !localpart:server). - if idx := strings.LastIndex(string(roomID), ":"); idx >= 0 { - add(string(roomID)[idx+1:]) - } - // Heroes from sync summary. - for _, h := range heroes { - _, server, err := h.Parse() - if err == nil && add(server) { - return servers - } - } - // Joined members from state events (present even when heroes summary is empty). - for _, ev := range stateEvents { - if len(servers) >= 3 { - break - } - if ev.Type != event.StateMember || ev.StateKey == nil { - continue - } - var mc struct { - Membership string `json:"membership"` - } - if ev.Content.VeryRaw != nil { - _ = json.Unmarshal(ev.Content.VeryRaw, &mc) - } - if mc.Membership != "join" { - continue - } - _, server, err := id.UserID(*ev.StateKey).Parse() - if err == nil { - add(server) - } - } - return servers -} - -// fetchRoomNameFromState fetches m.room.name then m.room.canonical_alias -// directly from the server state API. Used when the sync delta doesn't include -// those events (common for large/public rooms that haven't changed recently). -func fetchRoomNameFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID) string { - base := url.PathEscape(string(roomID)) - - var nameContent struct { - Name string `json:"name"` - } - if err := matrixGetJSON(ctx, client, - "/_matrix/client/v3/rooms/"+base+"/state/m.room.name", - &nameContent, - ); err == nil && nameContent.Name != "" { - return nameContent.Name - } - - var aliasContent struct { - Alias string `json:"alias"` - } - if err := matrixGetJSON(ctx, client, - "/_matrix/client/v3/rooms/"+base+"/state/m.room.canonical_alias", - &aliasContent, - ); err == nil && aliasContent.Alias != "" { - return aliasContent.Alias - } - - return "" -} - -// saveRoomList builds a JSON snapshot of all joined rooms (name, type, aliases, -// member count, encryption status) and uploads it to S3. -// -// On incremental syncs the server only returns rooms with recent activity, so -// we load the previously saved list and merge: rooms present in this sync get -// fresh data, rooms absent from this sync are kept as-is from the prior list. -func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) error { - // Load the previously persisted room list so that rooms absent from this - // sync's delta are not lost. - existing := make(map[string]roomEntry) - if raw, err := getDecryptedAgeFromS3(ctx, prefix+"/rooms-latest.json.age"); err == nil && raw != nil { - var prev []roomEntry - if json.Unmarshal(raw, &prev) == nil { - for _, r := range prev { - // Retroactively reclassify 2-member non-space rooms as DMs. - // This catches rooms stored as "normal" before the member-count - // heuristic was added (e.g. DMs created by the other party). - if r.Type == "normal" && r.MemberCount > 0 && r.MemberCount <= 2 { - r.Type = "dm" - } - existing[r.RoomID] = r - } - } - } - - dmRooms := getDMRooms(ctx, client, syncResp) - - for roomID, joinedRoom := range syncResp.Rooms.Join { - var name, canonicalAlias string - var aliases []string - encrypted := false - rtype := "normal" - if dmRooms[roomID] { - rtype = "dm" - } - - // ParseRaw must be called explicitly — mautrix only sets VeryRaw during - // JSON decode; the typed helpers (AsRoomName etc.) need Parsed != nil. - for _, ev := range joinedRoom.State.Events { - _ = ev.Content.ParseRaw(ev.Type) - } - - for _, ev := range joinedRoom.State.Events { - switch ev.Type { - case event.StateRoomName: - if c := ev.Content.AsRoomName(); c.Name != "" { - name = c.Name - } - case event.StateCanonicalAlias: - if c := ev.Content.AsCanonicalAlias(); c.Alias != "" { - 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" - } - } - } - } - - joinedCount, invitedCount := 0, 0 - if joinedRoom.Summary.JoinedMemberCount != nil { - joinedCount = *joinedRoom.Summary.JoinedMemberCount - } - if joinedRoom.Summary.InvitedMemberCount != nil { - invitedCount = *joinedRoom.Summary.InvitedMemberCount - } - - // A 2-member non-space room is effectively a DM even if not in m.direct - // (e.g. when the other party created the room and didn't populate our m.direct). - if rtype == "normal" && joinedCount > 0 && joinedCount <= 2 { - rtype = "dm" - } - - if name == "" { - name = calcRoomName(joinedRoom.State.Events, joinedRoom.Summary.Heroes, joinedCount, invitedCount, client.UserID) - } - if name == "" { - // Sync delta doesn't include the name — fetch it directly. - name = fetchRoomNameFromState(ctx, client, roomID) - } - // If we still have no name, keep whatever we had from the previous run. - if name == "" { - if prev, ok := existing[string(roomID)]; ok { - name = prev.Name - } - } - if name == "" { - name = string(roomID) - } - - existing[string(roomID)] = roomEntry{ - RoomID: string(roomID), - Name: name, - Type: rtype, - Aliases: aliases, - CanonicalAlias: canonicalAlias, - MemberCount: joinedCount, - Encrypted: encrypted, - ViaServers: collectViaServers(roomID, joinedRoom.Summary.Heroes, joinedRoom.State.Events), - } - slog.Info("Room", "type", rtype, "name", name) - } - - rooms := make([]roomEntry, 0, len(existing)) - for _, r := range existing { - rooms = append(rooms, r) - } - - data, err := json.MarshalIndent(rooms, "", " ") - if err != nil { - return err - } - // Age-encrypted copies — readable by both the backup tool (via ageIdentity) - // and the external decrypt utility. - if err := s3PutAge(ctx, prefix+"/rooms-"+dateStr+".json", data); err != nil { - return err - } - if err := s3PutAge(ctx, prefix+"/rooms-latest.json", data); err != nil { - return err - } - slog.Info("Uploaded room list", "rooms", len(rooms)) - return nil -} - -// getDMRooms returns the full set of room IDs marked as direct chats. -// It fetches m.direct via the account-data API so that incremental syncs -// (which only carry changed account-data events) don't lose the DM map. -func getDMRooms(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync) map[id.RoomID]bool { - dmRooms := make(map[id.RoomID]bool) - - populate := func(direct event.DirectChatsEventContent) { - for _, roomIDs := range direct { - for _, rid := range roomIDs { - dmRooms[rid] = true - } - } - } - - // Always fetch from the API — the sync delta only carries m.direct when it - // actually changes, so incremental runs would otherwise see an empty map. - var direct event.DirectChatsEventContent - if err := getAccountData(ctx, client, "m.direct", &direct); err == nil { - populate(direct) - return dmRooms - } - - // Fallback: use whatever the sync included (first run or API error). - for _, ev := range syncResp.AccountData.Events { - if ev.Type == event.AccountDataDirectChats { - if err := json.Unmarshal(ev.Content.VeryRaw, &direct); err == nil { - populate(direct) - } - break - } - } - return dmRooms -} - -// ───────────────────────────────────────────────────────────────────────────── -// Media download -// ───────────────────────────────────────────────────────────────────────────── - -// downloadAndStoreMedia downloads a single mxc:// URL and stores it under -// prefix/label/server/mediaID in S3, skipping if already present. -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 downloads media and records profile changes for a single event. -// The event should already be decrypted before this is called. -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"` - File json.RawMessage `json:"file"` - } - if err := json.Unmarshal(ev.Content.VeryRaw, &content); err == nil { - mediaURL := content.URL - if mediaURL == "" && content.File != nil { - var f struct{ URL string `json:"url"` } - if json.Unmarshal(content.File, &f) == nil { - mediaURL = f.URL - } - } - if mediaMsgTypes[content.MsgType] && mediaURL != "" { - if isDM || strings.HasSuffix(string(ev.Sender), ":"+ourServerName) { - if err := downloadAndStoreMedia(ctx, client, mediaURL, prefix, "media"); err != nil { - slog.Warn("Media download failed", "url", mediaURL, "error", err) - } - } - } - } - } - - if ev.Type == event.EventSticker { - var content struct { - URL string `json:"url"` - File json.RawMessage `json:"file"` - } - if err := json.Unmarshal(ev.Content.VeryRaw, &content); err == nil { - mediaURL := content.URL - if mediaURL == "" && content.File != nil { - var f struct{ URL string `json:"url"` } - if json.Unmarshal(content.File, &f) == nil { - mediaURL = f.URL - } - } - if mediaURL != "" { - if isDM || strings.HasSuffix(string(ev.Sender), ":"+ourServerName) { - if err := downloadAndStoreMedia(ctx, client, mediaURL, prefix, "media"); err != nil { - slog.Warn("Sticker download failed", "url", mediaURL, "error", err) - } - } - } - } - } - - if ev.Type == event.StateMember { - recordProfileUpdate(ctx, client, 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"` -} - -// recordProfileUpdate appends a JSONL entry to S3 when a room member event shows -// a display name or avatar change for a local user. Also downloads the new avatar. -func recordProfileUpdate(ctx context.Context, client *mautrix.Client, ev *event.Event, roomID id.RoomID, prefix string) { - if ev.StateKey == nil { - return - } - stateKey := *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"` - Encrypted bool `json:"encrypted,omitempty"` -} - -func eventToRecord(ev *event.Event, wasEncrypted bool) historyEvent { - return historyEvent{ - EventID: string(ev.ID), - Sender: string(ev.Sender), - Type: ev.Type.Type, - Timestamp: ev.Timestamp, - Content: ev.Content.VeryRaw, - Encrypted: wasEncrypted, - } -} - -// paginateRoom fetches room history and writes new events to a per-run -// age-encrypted JSONL file in S3. For DMs on first run, the full history is -// fetched backwards from the current position before normal forward pagination. -// -// syncNextBatch is the nextBatch token from the current sync response. On first -// run we save it as the forward cursor so the next run only sees events that -// arrive after this sync — avoiding re-fetching the events the sync already -// returned. -func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, prefix string, isDM bool, prevBatch string, syncNextBatch string, sessions megolmSessions) error { - safeKey := strings.NewReplacer("/", "_", ":", "_").Replace(string(roomID)) - cursorKey := prefix + "/history-cursor/" + safeKey + ".json" - dmHistoryDoneKey := prefix + "/history-cursor/" + safeKey + ".dm-done" - - var cursor struct { - Token string `json:"token"` - } - if err := s3GetJSON(ctx, cursorKey, &cursor); err != nil { - return err - } - - // fetchDMHistory does a full backward walk from prevBatch and stores results. - // Used on first-run for DMs, and also as a catch-up when a room was initially - // missed (e.g. getDMRooms bug meant isDM=false on first run). - fetchDMHistory := func(fromToken string) { - slog.Info("DM: fetching full history", "room_id", roomID, "from_token", fromToken) - var messages []historyEvent - token := fromToken - for { - resp, err := client.Messages(ctx, roomID, token, "", mautrix.DirectionBackward, nil, 100) - if err != nil { - slog.Warn("room_messages error (backward)", "room_id", roomID, "token", token, "error", err) - break - } - slog.Info("DM history page", "room_id", roomID, "chunk_size", len(resp.Chunk), "end", resp.End) - if len(resp.Chunk) == 0 { - break - } - for _, ev := range resp.Chunk { - wasEncrypted := ev.Type == event.EventEncrypted - ev = tryDecryptEvent(ev, sessions) - messages = append(messages, eventToRecord(ev, wasEncrypted)) - 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 + "/" + runStr + "-backfill.jsonl" - var buf bytes.Buffer - for _, m := range messages { - line, _ := json.Marshal(m) - buf.Write(line) - buf.WriteByte('\n') - } - if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil { - slog.Warn("Failed to upload DM history", "room_id", roomID, "error", err) - } else { - slog.Info("DM history stored", "room_id", roomID, "events", len(messages)) - _ = s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain") - } - } else { - // Nothing to backfill — mark as done so we don't retry every run. - slog.Info("DM history: no messages found, marking done", "room_id", roomID) - if err := s3Put(ctx, dmHistoryDoneKey, []byte("done"), "text/plain"); err != nil { - slog.Warn("Failed to write dm-done marker", "room_id", roomID, "error", err) - } - } - } - - if cursor.Token == "" { - // First time we've seen this room. For DMs, walk all the way back through - // history so we have a complete record from day one. - if isDM && prevBatch != "" { - fetchDMHistory(prevBatch) - } - // Save the sync's nextBatch as the forward cursor. This means the next - // run will forward-paginate from here and only pick up genuinely new - // events, not the batch we already received in this sync response. - if syncNextBatch == "" { - syncNextBatch = prevBatch - } - if syncNextBatch != "" { - cursorJSON, _ := json.Marshal(map[string]string{"token": syncNextBatch}) - _ = s3Put(ctx, cursorKey, cursorJSON, "application/json") - } - return nil - } - - // Incremental run: if this is a DM but we never completed a backward history - // fetch (e.g. room was missed because it wasn't in m.direct on first run), - // do it now using the saved cursor as the start for backward pagination. - // Note: prevBatch may be empty for rooms with no new events in a full_state - // sync, so we use cursor.Token directly rather than gating on prevBatch. - if isDM { - dmDoneData, _ := s3Get(ctx, dmHistoryDoneKey) - if dmDoneData == nil { - slog.Info("DM catch-up: no history marker found, backfilling", "room_id", roomID) - fetchDMHistory(cursor.Token) - } - } - - // Incremental run: fetch every page forward from the saved cursor until we - // reach the live end of the timeline. - var messages []historyEvent - nextToken := cursor.Token - lastGoodToken := cursor.Token - histKey := prefix + "/history/" + safeKey + "/" + runStr + ".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 { - // No events — we are already at the live end. - break - } - for _, ev := range resp.Chunk { - wasEncrypted := ev.Type == event.EventEncrypted - ev = tryDecryptEvent(ev, sessions) - messages = append(messages, eventToRecord(ev, wasEncrypted)) - processEvent(ctx, client, ev, roomID, prefix, isDM) - } - if resp.End == "" || resp.End == nextToken { - // Reached the live end of the timeline; nextToken is the furthest - // position we can name. - lastGoodToken = nextToken - nextToken = "" - break - } - lastGoodToken = resp.End - nextToken = resp.End - } - // If the loop hit the 100-page cap before reaching live end, nextToken still - // holds the resume position for the next run. - if nextToken != "" { - lastGoodToken = nextToken - } - - if len(messages) > 0 { - var buf bytes.Buffer - for _, m := range messages { - line, _ := json.Marshal(m) - buf.Write(line) - buf.WriteByte('\n') - } - if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil { - slog.Warn("Failed to upload history", "room_id", roomID, "error", err) - } - slog.Info("History updated", "room_id", roomID, "new_events", len(messages)) - } - - // Always advance the cursor so the next run never re-processes events we've - // already stored. - if lastGoodToken != cursor.Token { - cursorJSON, _ := json.Marshal(map[string]string{"token": lastGoodToken}) - _ = s3Put(ctx, cursorKey, cursorJSON, "application/json") - } - return nil -} - -// ───────────────────────────────────────────────────────────────────────────── -// Per-account backup -// ───────────────────────────────────────────────────────────────────────────── - -// backupAccount runs the full backup pipeline for a single Matrix account: -// restore state, sync, export E2EE keys, save room list, paginate history, -// then persist state back to S3. -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)) - - var sessions megolmSessions - if acc.SSSSKey != "" { - var err error - sessions, err = fetchAndExportKeyBackup(ctx, client, acc.SSSSKey, acc.Prefix) - if 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(ctx, client, syncResp) - // Augment dmRooms with rooms marked as "dm" in the stored rooms list. - // This catches rooms classified as DMs by the 2-member heuristic in - // saveRoomList that aren't in m.direct (e.g. created by the other party). - if raw, err := getDecryptedAgeFromS3(ctx, acc.Prefix+"/rooms-latest.json.age"); err == nil && raw != nil { - var storedRooms []roomEntry - if json.Unmarshal(raw, &storedRooms) == nil { - for _, r := range storedRooms { - if r.Type == "dm" { - dmRooms[id.RoomID(r.RoomID)] = true - } - } - } - } - for roomID, joinedRoom := range syncResp.Rooms.Join { - isDM := dmRooms[roomID] - if err := paginateRoom(ctx, client, roomID, acc.Prefix, isDM, joinedRoom.Timeline.PrevBatch, syncResp.NextBatch, sessions); err != nil { - slog.Warn("History error", "room_id", roomID, "error", err) - } - } - - // Catch-up: DM rooms absent from this sync delta (no new events) may still - // need their history backfilled. Use the rooms list just written by saveRoomList - // (which also detects 2-member rooms as DMs) as the authoritative DM source, - // falling back to the API-based dmRooms map. - catchupDMs := make(map[id.RoomID]bool) - for roomID := range dmRooms { - catchupDMs[roomID] = true - } - if raw, err := getDecryptedAgeFromS3(ctx, acc.Prefix+"/rooms-latest.json.age"); err != nil { - slog.Warn("DM catch-up: failed to read rooms list", "error", err) - } else if raw == nil { - slog.Warn("DM catch-up: rooms list not found or ageIdentity not set") - } else { - var storedRooms []roomEntry - if err := json.Unmarshal(raw, &storedRooms); err != nil { - slog.Warn("DM catch-up: failed to parse rooms list", "error", err) - } else { - slog.Info("DM catch-up: loaded rooms list", "total_rooms", len(storedRooms)) - for _, r := range storedRooms { - if r.Type == "dm" { - catchupDMs[id.RoomID(r.RoomID)] = true - } - } - } - } - slog.Info("DM catch-up: checking rooms", "total_dm_rooms", len(catchupDMs), "in_sync", len(syncResp.Rooms.Join)) - for roomID := range catchupDMs { - if _, inSync := syncResp.Rooms.Join[roomID]; inSync { - continue // already handled above - } - safeKey := strings.NewReplacer("/", "_", ":", "_").Replace(string(roomID)) - dmHistoryDoneKey := acc.Prefix + "/history-cursor/" + safeKey + ".dm-done" - dmDone, dmDoneErr := s3Get(ctx, dmHistoryDoneKey) - if dmDoneErr != nil { - slog.Warn("DM catch-up: error checking dm-done marker", "room_id", roomID, "error", dmDoneErr) - } - if dmDone != nil { - continue // already backfilled - } - cursorKey := acc.Prefix + "/history-cursor/" + safeKey + ".json" - var cursor struct { - Token string `json:"token"` - } - if err := s3GetJSON(ctx, cursorKey, &cursor); err != nil { - slog.Warn("DM catch-up: error reading cursor", "room_id", roomID, "error", err) - continue - } - if cursor.Token == "" { - slog.Info("DM catch-up: no cursor yet, skipping", "room_id", roomID) - continue - } - slog.Info("DM catch-up: backfilling room", "room_id", roomID, "cursor", cursor.Token) - if err := paginateRoom(ctx, client, roomID, acc.Prefix, true, cursor.Token, syncResp.NextBatch, sessions); err != nil { - slog.Warn("DM catch-up 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, diff --git a/apps/talos_cluster/matrix-backup/backup-tool/matrix.go b/apps/talos_cluster/matrix-backup/backup-tool/matrix.go @@ -0,0 +1,47 @@ +// matrix.go — low-level Matrix client helpers not wrapped by mautrix. +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "maunium.net/go/mautrix" +) + +// matrixGetJSON makes an authenticated GET to the homeserver and JSON-decodes +// the response. Used for endpoints not wrapped by the mautrix client. +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) +} + +// getAccountData fetches a single account-data event for the authenticated user. +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) +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/media.go b/apps/talos_cluster/matrix-backup/backup-tool/media.go @@ -0,0 +1,117 @@ +// media.go — Matrix media download and S3 storage. +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "maunium.net/go/mautrix" +) + +// mediaHTTPClient is used for all media downloads; the 5-minute timeout +// prevents a single large file from stalling the entire backup run. +var mediaHTTPClient = &http.Client{Timeout: 5 * time.Minute} + +var mediaMsgTypes = map[string]bool{ + "m.image": true, + "m.file": true, + "m.video": true, + "m.audio": true, +} + +// downloadAndStoreMedia downloads a single mxc:// URL and stores it under +// prefix/label/server/mediaID in S3, skipping if already present. +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 := mediaHTTPClient.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 "" +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/rooms.go b/apps/talos_cluster/matrix-backup/backup-tool/rooms.go @@ -0,0 +1,356 @@ +// rooms.go — room list management: name calculation, DM detection, S3 persistence. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "net/url" +) + +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"` + ViaServers []string `json:"via_servers,omitempty"` +} + +// calcRoomName implements the Matrix room display-name algorithm: +// explicit name → canonical alias → heroes → "Empty Room". +func calcRoomName( + stateEvents []*event.Event, + heroes []id.UserID, + joinedCount, invitedCount int, + selfUserID id.UserID, +) string { + for _, ev := range stateEvents { + if ev.Type == event.StateRoomName { + if c := ev.Content.AsRoomName(); c != nil && c.Name != "" { + return c.Name + } + } + } + for _, ev := range stateEvents { + if ev.Type == event.StateCanonicalAlias { + if c := ev.Content.AsCanonicalAlias(); c != nil && c.Alias != "" { + return string(c.Alias) + } + } + } + + memberNames := make(map[id.UserID]string) + for _, ev := range stateEvents { + if ev.Type == event.StateMember && ev.StateKey != nil { + uid := id.UserID(*ev.StateKey) + if c := ev.Content.AsMember(); c != nil && c.Displayname != "" { + memberNames[uid] = c.Displayname + } else { + local, _, _ := uid.Parse() + if local != "" { + memberNames[uid] = local + } else { + memberNames[uid] = string(uid) + } + } + } + } + + var filtered []id.UserID + for _, h := range heroes { + if h != selfUserID { + filtered = append(filtered, h) + } + } + + tentativeNames := make(map[id.UserID]string, len(filtered)) + for _, h := range filtered { + if n, ok := memberNames[h]; ok { + tentativeNames[h] = n + } else { + local, _, _ := h.Parse() + if local != "" { + tentativeNames[h] = local + } else { + tentativeNames[h] = string(h) + } + } + } + nameCounts := make(map[string]int, len(filtered)) + for _, n := range tentativeNames { + nameCounts[n]++ + } + heroName := func(uid id.UserID) string { + n := tentativeNames[uid] + if nameCounts[n] > 1 { + _, server, err := uid.Parse() + if err == nil && server != "" { + return n + " (" + server + ")" + } + return string(uid) + } + return n + } + + others := joinedCount + invitedCount - 1 + if others < 0 { + others = 0 + } + if len(filtered) == 0 { + if others == 0 { + return "Empty Room" + } + return "" + } + + names := make([]string, len(filtered)) + for i, h := range filtered { + names[i] = heroName(h) + } + unnamed := others - len(names) + if unnamed <= 0 { + switch len(names) { + case 1: + return names[0] + case 2: + return names[0] + " and " + names[1] + default: + return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] + } + } + switch len(names) { + case 1: + return fmt.Sprintf("%s and %d others", names[0], unnamed) + case 2: + return fmt.Sprintf("%s, %s, and %d others", names[0], names[1], unnamed) + default: + return fmt.Sprintf("%s, and %d others", strings.Join(names, ", "), unnamed) + } +} + +// collectViaServers returns up to 3 server names for matrix.to ?via= links. +func collectViaServers(roomID id.RoomID, heroes []id.UserID, stateEvents []*event.Event) []string { + seen := make(map[string]bool) + var servers []string + add := func(s string) bool { + if s != "" && !seen[s] { + seen[s] = true + servers = append(servers, s) + } + return len(servers) >= 3 + } + if idx := strings.LastIndex(string(roomID), ":"); idx >= 0 { + add(string(roomID)[idx+1:]) + } + for _, h := range heroes { + _, server, err := h.Parse() + if err == nil && add(server) { + return servers + } + } + for _, ev := range stateEvents { + if len(servers) >= 3 { + break + } + if ev.Type != event.StateMember || ev.StateKey == nil { + continue + } + var mc struct { + Membership string `json:"membership"` + } + if ev.Content.VeryRaw != nil { + _ = json.Unmarshal(ev.Content.VeryRaw, &mc) + } + if mc.Membership != "join" { + continue + } + _, server, err := id.UserID(*ev.StateKey).Parse() + if err == nil { + add(server) + } + } + return servers +} + +// fetchRoomNameFromState fetches the room name directly from the state API. +// Used when the sync delta doesn't include the name event. +func fetchRoomNameFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID) string { + base := url.PathEscape(string(roomID)) + var nameContent struct { + Name string `json:"name"` + } + if err := matrixGetJSON(ctx, client, + "/_matrix/client/v3/rooms/"+base+"/state/m.room.name", + &nameContent, + ); err == nil && nameContent.Name != "" { + return nameContent.Name + } + var aliasContent struct { + Alias string `json:"alias"` + } + if err := matrixGetJSON(ctx, client, + "/_matrix/client/v3/rooms/"+base+"/state/m.room.canonical_alias", + &aliasContent, + ); err == nil && aliasContent.Alias != "" { + return aliasContent.Alias + } + return "" +} + +// saveRoomList builds a JSON snapshot of all joined rooms and uploads it to S3. +// Rooms absent from this sync delta are preserved from the prior list. +func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) error { + existing := make(map[string]roomEntry) + if raw, err := getDecryptedAgeFromS3(ctx, prefix+"/rooms-latest.json.age"); err != nil { + slog.Warn("Could not load previous room list", "error", err) + } else if raw != nil { + var prev []roomEntry + if err := json.Unmarshal(raw, &prev); err != nil { + slog.Warn("Previous room list is corrupt, starting fresh", "error", err) + } else { + for _, r := range prev { + // Retroactively reclassify 2-member non-space rooms stored as + // "normal" before the member-count heuristic was introduced. + if r.Type == "normal" && r.MemberCount > 0 && r.MemberCount <= 2 { + r.Type = "dm" + } + existing[r.RoomID] = r + } + } + } + + dmRooms := getDMRooms(ctx, client, syncResp) + + 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 { + _ = ev.Content.ParseRaw(ev.Type) + } + for _, ev := range joinedRoom.State.Events { + switch ev.Type { + case event.StateRoomName: + if c := ev.Content.AsRoomName(); c.Name != "" { + name = c.Name + } + case event.StateCanonicalAlias: + if c := ev.Content.AsCanonicalAlias(); c.Alias != "" { + 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" + } + } + } + } + + joinedCount, invitedCount := 0, 0 + if joinedRoom.Summary.JoinedMemberCount != nil { + joinedCount = *joinedRoom.Summary.JoinedMemberCount + } + if joinedRoom.Summary.InvitedMemberCount != nil { + invitedCount = *joinedRoom.Summary.InvitedMemberCount + } + if rtype == "normal" && joinedCount > 0 && joinedCount <= 2 { + rtype = "dm" + } + + if name == "" { + name = calcRoomName(joinedRoom.State.Events, joinedRoom.Summary.Heroes, joinedCount, invitedCount, client.UserID) + } + if name == "" { + name = fetchRoomNameFromState(ctx, client, roomID) + } + if name == "" { + if prev, ok := existing[string(roomID)]; ok { + name = prev.Name + } + } + if name == "" { + name = string(roomID) + } + + existing[string(roomID)] = roomEntry{ + RoomID: string(roomID), + Name: name, + Type: rtype, + Aliases: aliases, + CanonicalAlias: canonicalAlias, + MemberCount: joinedCount, + Encrypted: encrypted, + ViaServers: collectViaServers(roomID, joinedRoom.Summary.Heroes, joinedRoom.State.Events), + } + slog.Info("Room", "type", rtype, "name", name) + } + + rooms := make([]roomEntry, 0, len(existing)) + for _, r := range existing { + rooms = append(rooms, r) + } + data, err := json.MarshalIndent(rooms, "", " ") + if err != nil { + return err + } + if err := s3PutAge(ctx, prefix+"/rooms-"+dateStr+".json", data); err != nil { + return err + } + if err := s3PutAge(ctx, prefix+"/rooms-latest.json", data); err != nil { + return err + } + slog.Info("Uploaded room list", "rooms", len(rooms)) + return nil +} + +// getDMRooms returns the full set of room IDs marked as direct chats. +// Always fetches from the account-data API so incremental syncs (which only +// carry changed account-data events) don't see a stale map. +func getDMRooms(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync) map[id.RoomID]bool { + dmRooms := make(map[id.RoomID]bool) + populate := func(direct event.DirectChatsEventContent) { + for _, roomIDs := range direct { + for _, rid := range roomIDs { + dmRooms[rid] = true + } + } + } + var direct event.DirectChatsEventContent + if err := getAccountData(ctx, client, "m.direct", &direct); err == nil { + populate(direct) + return dmRooms + } + // Fallback: use whatever the sync included. + for _, ev := range syncResp.AccountData.Events { + if ev.Type == event.AccountDataDirectChats { + if err := json.Unmarshal(ev.Content.VeryRaw, &direct); err == nil { + populate(direct) + } + break + } + } + return dmRooms +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/s3.go b/apps/talos_cluster/matrix-backup/backup-tool/s3.go @@ -0,0 +1,154 @@ +// s3.go — S3 client initialisation, helpers, and age encryption/decryption. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + + "filippo.io/age" + "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" +) + +var s3c *s3.Client + +func initS3(ctx context.Context) error { + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(s3Region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + s3AccessKey, s3SecretKey, "", + )), + ) + if err != nil { + return err + } + endpoint := "https://" + s3Endpoint + s3c = s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true + o.BaseEndpoint = &endpoint + }) + 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 +} + +// s3DeletePrefix deletes all objects whose key starts with prefix. +func s3DeletePrefix(ctx context.Context, prefix string) error { + paginator := s3.NewListObjectsV2Paginator(s3c, &s3.ListObjectsV2Input{ + Bucket: aws.String(s3BucketName), + Prefix: aws.String(prefix), + }) + deleted := 0 + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return err + } + for _, obj := range page.Contents { + if _, err := s3c.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s3BucketName), + Key: obj.Key, + }); err != nil { + slog.Warn("Failed to delete S3 object", "key", *obj.Key, "error", err) + } else { + deleted++ + } + } + } + slog.Info("Deleted S3 prefix", "prefix", prefix, "count", deleted) + return nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// Age encryption / decryption +// ───────────────────────────────────────────────────────────────────────────── + +// ageEncrypt encrypts data for all ageRecipients and returns the ciphertext. +func ageEncrypt(data []byte) ([]byte, error) { + var buf bytes.Buffer + w, err := age.Encrypt(&buf, ageRecipients...) + if err != nil { + return nil, err + } + if _, err := w.Write(data); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// getDecryptedAgeFromS3 fetches an .age object from S3 and decrypts it. +// Returns (nil, nil) when the object doesn't exist or no identity is available. +func getDecryptedAgeFromS3(ctx context.Context, key string) ([]byte, error) { + if ageIdentity == nil { + return nil, nil + } + enc, err := s3Get(ctx, key) + if err != nil || enc == nil { + return nil, err + } + r, err := age.Decrypt(bytes.NewReader(enc), ageIdentity) + if err != nil { + return nil, fmt.Errorf("age decrypt %s: %w", key, err) + } + return io.ReadAll(r) +} + +// s3PutAge age-encrypts data then uploads it under key+".age". +func s3PutAge(ctx context.Context, key string, data []byte) error { + enc, err := ageEncrypt(data) + if err != nil { + return fmt.Errorf("age encrypt %s: %w", key, err) + } + return s3Put(ctx, key+".age", enc, "application/octet-stream") +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/session.go b/apps/talos_cluster/matrix-backup/backup-tool/session.go @@ -0,0 +1,81 @@ +// session.go — Matrix session persistence (login, save, restore). +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type sessionData struct { + AccessToken string `json:"access_token"` + DeviceID id.DeviceID `json:"device_id"` +} + +// ensureSession loads a saved session from S3 or logs in with the account +// password and persists the new session for future runs. +// +// Sessions are stored age-encrypted under s3Key+".age". Plain-JSON sessions +// written by older tool versions are transparently read as a fallback so +// existing deployments migrate automatically on the next run. +func ensureSession(ctx context.Context, client *mautrix.Client, acc accountCfg, s3Key string) (*sessionData, error) { + var sess sessionData + + // Try age-encrypted first (current format). + if raw, err := getDecryptedAgeFromS3(ctx, s3Key+".age"); err != nil { + return nil, fmt.Errorf("load session: %w", err) + } else if raw != nil { + if err := json.Unmarshal(raw, &sess); err != nil { + return nil, fmt.Errorf("parse session: %w", err) + } + } else { + // Fallback: plain JSON written by older versions. + if err := s3GetJSON(ctx, s3Key, &sess); err != nil { + return nil, fmt.Errorf("load session (legacy): %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, + InitialDeviceDisplayName: "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 + } + // Write age-encrypted; appends ".age" to s3Key. + if err := s3PutAge(ctx, s3Key, data); err != nil { + return nil, fmt.Errorf("save session: %w", err) + } + return &sess, nil +} diff --git a/apps/talos_cluster/matrix-backup/backup-tool/store.go b/apps/talos_cluster/matrix-backup/backup-tool/store.go @@ -0,0 +1,134 @@ +// store.go — crypto-store tarball backup and restore (persists Megolm sessions +// and sync tokens across CronJob runs via S3). +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" +) + +// downloadStore restores a previously uploaded crypto store tarball from S3. +// A missing store (first run) is not an error — the caller starts fresh. +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 +} + +// uploadStore tarballs the crypto store directory and uploads it to S3 for +// the next run to restore. +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 + } + if err := tw.Close(); err != nil { + return fmt.Errorf("finalize tar: %w", err) + } + if err := gw.Close(); err != nil { + return fmt.Errorf("finalize gzip: %w", err) + } + 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 +}