cluster

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

commit 2f5acc36d3a79e0b8d82341994274fc1c62ee2f0
parent bdcff1962b0bead4af6a601de8d29a46c4d3bb5d
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Sat,  4 Apr 2026 02:17:25 +0200

improve the backup tool

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

Diffstat:
Mapps/talos_cluster/matrix-backup/backup-cronjob.yaml | 5+++++
Mapps/talos_cluster/matrix-backup/backup-tool/go.mod | 1+
Mapps/talos_cluster/matrix-backup/backup-tool/main.go | 259++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapps/talos_cluster/matrix-backup/secret.yaml | 29+++++++++++++++--------------
4 files changed, 225 insertions(+), 69 deletions(-)

diff --git a/apps/talos_cluster/matrix-backup/backup-cronjob.yaml b/apps/talos_cluster/matrix-backup/backup-cronjob.yaml @@ -80,6 +80,11 @@ spec: secretKeyRef: name: matrix-backup-secret key: key_export_passphrase + - name: AGE_RECIPIENTS + valueFrom: + secretKeyRef: + name: matrix-backup-secret + key: age_recipients volumeMounts: - name: data mountPath: /data diff --git a/apps/talos_cluster/matrix-backup/backup-tool/go.mod b/apps/talos_cluster/matrix-backup/backup-tool/go.mod @@ -3,6 +3,7 @@ module github.com/mtrnord/matrix-backup go 1.25 require ( + filippo.io/age v1.2.1 github.com/aws/aws-sdk-go-v2 v1.30.3 github.com/aws/aws-sdk-go-v2/config v1.27.26 github.com/aws/aws-sdk-go-v2/credentials v1.17.26 diff --git a/apps/talos_cluster/matrix-backup/backup-tool/main.go b/apps/talos_cluster/matrix-backup/backup-tool/main.go @@ -2,6 +2,8 @@ // homeserver and stores them in S3. Accounts are configured via environment // variables; see the deployment CronJob manifest for the full list. // +// Room history is decrypted using the Megolm sessions fetched from the +// server-side key backup, then re-encrypted with age before uploading to S3. // Session tokens and the device crypto store are persisted as a tarball in S3 // so incremental sync works across CronJob runs. // @@ -32,6 +34,7 @@ import ( "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" @@ -40,6 +43,8 @@ import ( "golang.org/x/crypto/pbkdf2" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/backup" + _ "maunium.net/go/mautrix/crypto/goolm" // registers goolm implementations for olm package + "maunium.net/go/mautrix/crypto/olm" "maunium.net/go/mautrix/crypto/ssss" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -67,6 +72,7 @@ var ( 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{ { @@ -84,6 +90,9 @@ var ( StoreDir: "/data/crypto/lexi", }, } + + // ageRecipients holds the parsed public keys used to encrypt history files. + ageRecipients []age.Recipient ) func mustEnv(key string) string { @@ -95,6 +104,55 @@ func mustEnv(key string) string { 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 +} + +// ───────────────────────────────────────────────────────────────────────────── +// Age encryption +// ───────────────────────────────────────────────────────────────────────────── + +// 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 +} + +// 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 // ───────────────────────────────────────────────────────────────────────────── @@ -161,6 +219,33 @@ func s3Exists(ctx context.Context, key string) bool { 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) // ───────────────────────────────────────────────────────────────────────────── @@ -417,59 +502,63 @@ type exportedSessionEntry struct { SessionKey string `json:"session_key"` } +// megolmSessions maps session IDs to live inbound sessions ready to decrypt. +type megolmSessions map[id.SessionID]olm.InboundGroupSession + // 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. -func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recoveryKeyStr, prefix string) error { +// 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 fmt.Errorf("get default key: %w", err) + return nil, fmt.Errorf("get default key: %w", err) } if defaultKey.Key == "" { - return fmt.Errorf("m.secret_storage.default_key has no 'key' field") + 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 fmt.Errorf("get key metadata: %w", err) + 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 fmt.Errorf("verify recovery key: %w", err) + 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 fmt.Errorf("get backup secret: %w", err) + return nil, fmt.Errorf("get backup secret: %w", err) } encData, ok := backupSecret.Encrypted[keyID] if !ok { - return fmt.Errorf("backup secret not encrypted with key %q", keyID) + 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 fmt.Errorf("decrypt backup key from SSSS: %w", err) + return nil, fmt.Errorf("decrypt backup key from SSSS: %w", err) } privateKeyBytes, err := decodeBackupPrivateKey(backupKeyRaw) if err != nil { - return fmt.Errorf("decode backup private key: %w", err) + return nil, fmt.Errorf("decode backup private key: %w", err) } megolmKey, err := backup.MegolmBackupKeyFromBytes(privateKeyBytes) if err != nil { - return fmt.Errorf("create megolm backup key: %w", err) + 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 fmt.Errorf("get backup version: %w", err) + return nil, fmt.Errorf("get backup version: %w", err) } slog.Info("Key backup version", "version", backupVersion.Version) @@ -478,11 +567,13 @@ func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recove "/_matrix/client/v3/room_keys/keys?version="+url.QueryEscape(backupVersion.Version), &allRooms, ); err != nil { - return fmt.Errorf("get backup keys: %w", err) + return nil, fmt.Errorf("get backup keys: %w", err) } slog.Info("Fetched key backup", "rooms", len(allRooms.Rooms)) - var sessions []exportedSessionEntry + // 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 { @@ -500,7 +591,15 @@ func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recove decFail++ continue } - sessions = append(sessions, exportedSessionEntry{ + + // Build the in-memory session for history decryption. + if sess, err := olm.InboundGroupSessionImport([]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), @@ -512,30 +611,30 @@ func fetchAndExportKeyBackup(ctx context.Context, client *mautrix.Client, recove decOK++ } } - slog.Info("Session decryption complete", "ok", decOK, "failed", decFail) + slog.Info("Session decryption complete", "ok", decOK, "failed", decFail, "in_memory", len(sessions)) - if len(sessions) == 0 { + if len(exported) == 0 { slog.Warn("No sessions decrypted — skipping key export") - return nil + return sessions, nil } - if rawJSON, err := json.MarshalIndent(sessions, "", " "); err == nil { - _ = s3Put(ctx, prefix+"/backup-sessions-latest.json", rawJSON, "application/json") - _ = s3Put(ctx, prefix+"/backup-sessions-"+dateStr+".json", rawJSON, "application/json") + 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(sessions, keyExportPass) + exportData, err := buildMegolmExport(exported, keyExportPass) if err != nil { - return fmt.Errorf("build megolm export: %w", err) + return sessions, fmt.Errorf("build megolm export: %w", err) } - if err := s3Put(ctx, prefix+"/crypto-keys-latest.bin", exportData, "application/octet-stream"); err != nil { - return err + if err := s3PutAge(ctx, prefix+"/crypto-keys-latest.bin", exportData); err != nil { + return sessions, err } - if err := s3Put(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData, "application/octet-stream"); err != nil { - return err + if err := s3PutAge(ctx, prefix+"/crypto-keys-"+dateStr+".bin", exportData); err != nil { + return sessions, err } - slog.Info("Exported E2EE keys", "sessions", len(sessions), "bytes", len(exportData)) - return nil + slog.Info("Exported E2EE keys", "sessions", len(exported), "bytes", len(exportData)) + return sessions, nil } // decodeBackupPrivateKey extracts the raw 32-byte Curve25519 private key from @@ -561,6 +660,43 @@ func decodeBackupPrivateKey(raw []byte) ([]byte, error) { } // ───────────────────────────────────────────────────────────────────────────── +// 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 + } + content := ev.Content.AsEncrypted() + if content == nil || 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) // ───────────────────────────────────────────────────────────────────────────── @@ -679,19 +815,18 @@ func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix if name == "" { name = string(roomID) } + mc := 0 + if joinedRoom.Summary.JoinedMemberCount != nil { + mc = *joinedRoom.Summary.JoinedMemberCount + } rooms = append(rooms, roomEntry{ RoomID: string(roomID), Name: name, Type: rtype, Aliases: aliases, CanonicalAlias: canonicalAlias, - MemberCount: func() int { - if joinedRoom.Summary.JoinedMemberCount != nil { - return *joinedRoom.Summary.JoinedMemberCount - } - return 0 - }(), - Encrypted: encrypted, + MemberCount: mc, + Encrypted: encrypted, }) slog.Info("Room", "type", rtype, "name", name) } @@ -700,10 +835,10 @@ func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix if err != nil { return err } - if err := s3Put(ctx, prefix+"/rooms-"+dateStr+".json", data, "application/json"); err != nil { + if err := s3PutAge(ctx, prefix+"/rooms-"+dateStr+".json", data); err != nil { return err } - if err := s3Put(ctx, prefix+"/rooms-latest.json", data, "application/json"); err != nil { + if err := s3PutAge(ctx, prefix+"/rooms-latest.json", data); err != nil { return err } slog.Info("Uploaded room list", "rooms", len(rooms)) @@ -765,7 +900,6 @@ func downloadAndStoreMedia(ctx context.Context, client *mautrix.Client, mxcURL, return err } req.Header.Set("Authorization", "Bearer "+client.AccessToken) - resp, err := http.DefaultClient.Do(req) if err != nil { return err @@ -838,8 +972,7 @@ var mediaMsgTypes = map[string]bool{ } // processEvent downloads media and records profile changes for a single event. -// Encrypted events are stored verbatim — without an OlmMachine we can't decrypt -// them at backup time, so media extraction is skipped for those. +// 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 @@ -951,22 +1084,24 @@ type historyEvent struct { 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) historyEvent { +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 appends new events to the per-room -// JSONL file in S3. For DMs on first run, the full history is fetched -// backwards from the current position before normal forward pagination begins. -func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, prefix string, isDM bool, prevBatch string) error { +// 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. +func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, prefix string, isDM bool, prevBatch string, sessions megolmSessions) error { safeKey := strings.NewReplacer("/", "_", ":", "_").Replace(string(roomID)) cursorKey := prefix + "/history-cursor/" + safeKey + ".json" @@ -995,7 +1130,9 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, break } for _, ev := range resp.Chunk { - messages = append(messages, eventToRecord(ev)) + 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 { @@ -1007,14 +1144,16 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { messages[i], messages[j] = messages[j], messages[i] } - histKey := prefix + "/history/" + safeKey + "/" + dateStr + ".jsonl" + histKey := prefix + "/history/" + safeKey + "/" + runStr + ".jsonl" var buf bytes.Buffer for _, m := range messages { line, _ := json.Marshal(m) buf.Write(line) buf.WriteByte('\n') } - _ = s3Put(ctx, histKey, buf.Bytes(), "application/x-ndjson") + if err := s3PutAge(ctx, histKey, buf.Bytes()); err != nil { + slog.Warn("Failed to upload DM history", "room_id", roomID, "error", err) + } slog.Info("DM history stored", "room_id", roomID, "events", len(messages)) } } @@ -1025,7 +1164,7 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, var messages []historyEvent nextToken := cursor.Token - histKey := prefix + "/history/" + safeKey + "/" + dateStr + ".jsonl" + histKey := prefix + "/history/" + safeKey + "/" + runStr + ".jsonl" for range 100 { resp, err := client.Messages(ctx, roomID, nextToken, "", mautrix.DirectionForward, nil, 100) @@ -1037,7 +1176,9 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, break } for _, ev := range resp.Chunk { - messages = append(messages, eventToRecord(ev)) + 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 { @@ -1048,15 +1189,15 @@ func paginateRoom(ctx context.Context, client *mautrix.Client, roomID id.RoomID, } if len(messages) > 0 { - existing, _ := s3Get(ctx, histKey) var buf bytes.Buffer - buf.Write(existing) for _, m := range messages { line, _ := json.Marshal(m) buf.Write(line) buf.WriteByte('\n') } - _ = s3Put(ctx, histKey, buf.Bytes(), "application/x-ndjson") + 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)) } @@ -1109,8 +1250,11 @@ func backupAccount(ctx context.Context, acc accountCfg) error { } slog.Info("Sync done", "rooms", len(syncResp.Rooms.Join)) + var sessions megolmSessions if acc.SSSSKey != "" { - if err := fetchAndExportKeyBackup(ctx, client, acc.SSSSKey, acc.Prefix); err != nil { + var err error + sessions, err = fetchAndExportKeyBackup(ctx, client, acc.SSSSKey, acc.Prefix) + if err != nil { slog.Warn("Key backup export failed", "error", err) } } @@ -1122,7 +1266,7 @@ func backupAccount(ctx context.Context, acc accountCfg) error { dmRooms := getDMRooms(syncResp) for roomID, joinedRoom := range syncResp.Rooms.Join { isDM := dmRooms[roomID] - if err := paginateRoom(ctx, client, roomID, acc.Prefix, isDM, joinedRoom.Timeline.PrevBatch); err != nil { + if err := paginateRoom(ctx, client, roomID, acc.Prefix, isDM, joinedRoom.Timeline.PrevBatch, sessions); err != nil { slog.Warn("History error", "room_id", roomID, "error", err) } } @@ -1146,6 +1290,11 @@ func main() { ctx := context.Background() + if err := initAgeRecipients(mustEnv("AGE_RECIPIENTS")); err != nil { + slog.Error("Failed to init age recipients", "error", err) + os.Exit(1) + } + if err := initS3(ctx); err != nil { slog.Error("Failed to init S3", "error", err) os.Exit(1) diff --git a/apps/talos_cluster/matrix-backup/secret.yaml b/apps/talos_cluster/matrix-backup/secret.yaml @@ -5,13 +5,14 @@ metadata: namespace: matrix-backup type: Opaque stringData: - mtrnord_password: ENC[AES256_GCM,data:rLhj2nEU49Vi,iv:spylZf2eUmJAz1sxYYs3FH7ogyvL/Uh0IRn3gIxEOh4=,tag:TdiNa8DLfSUThCoKqCw4tA==,type:str] - mtrnord_ssss_recovery_key: ENC[AES256_GCM,data:ETn7dgqZpDCaLk8JZN/8ALsoL59USF/QWhGpZcsyqDM6bkSjBNPPiXr7Sbs/5rnFQH4NY0MRlpdG4Yc=,iv:VUXCLS4mwMmmviI0ZrttaKFRpFGNDnF9dzCJ4RREfTc=,tag:yBmhc/aX5+U/Y4q5bmp3ng==,type:str] - lexi_password: ENC[AES256_GCM,data:QVU6xUkfHcys,iv:poksZTGryhSU6S7M5K4uUjhxflgT3AwOiThgG9rf9nk=,tag:vnEr1NtWa3PSR+cz+7WFwQ==,type:str] - lexi_ssss_recovery_key: ENC[AES256_GCM,data:/uK2cf5d65VrU4/rentCPHLMdTkvc5gVL1yOJmi4LUq/L4RReO2tgAq+ZBl9fPaJYVdsQRlSlOojWjU=,iv:Q7gurpu4xJmO8NNM7yXpHFYzu60IMYCugIRmJTXrmRI=,tag:K8+hXLX5yv4ZmRr0iOXxcg==,type:str] - s3_access_key: ENC[AES256_GCM,data:idq3pSR7TekY9Pcr4DLnLIK0Awk=,iv:Y3awxY8Jau+niPqhdYST/ekm75g6nES2HY8NOghr1gM=,tag:ymp64EtXsHv5hxmJJ2QMcQ==,type:str] - s3_secret_key: ENC[AES256_GCM,data:7U2jWXr87Rj6f3b80C5DZM9K1NSkIbzCXPN9ukecz2owhajeyuQ8hQ==,iv:r4XpfSjn2cAvOxbAcUbL8gB7+2CA7geioJhpdETcqac=,tag:b0qx8wJEQhFT41+JPiBm4w==,type:str] - key_export_passphrase: ENC[AES256_GCM,data:yB1+XS1YoiAAL23Mu2a/V0sI/fGOGN9vaKyH9ePw/yvA66J1QoRBXKhxr4rJCTkYhfzaCn4IoTLrFPxIvvzZ,iv:RV9KjtobYjsz7jVSvCaD7SOJW5Wxt9na9SPjArT67xg=,tag:WcAkwSgCzc5Mdb96OrkBfA==,type:str] + mtrnord_password: ENC[AES256_GCM,data:wpvzy453lYFp,iv:H+55b0Q1mrpGwyzuI/e1uC4+zm/sF9Y3xrfitgeJK7Y=,tag:zAXmD7JG9LKe7EnD5WMUSw==,type:str] + mtrnord_ssss_recovery_key: ENC[AES256_GCM,data:7t4pVgEWg/K80Ff8IZOn/eYk9Ws/D9eP/0UKb5Zb0dFKLZ3tgKvrcy5WtUtKBQhQDTLXQKn2p2yGSEE=,iv:GhQnpk9KcDI+EYNWTN4HS+e/lPboBwTjmHoXmODXLss=,tag:QrGPGONB4Ri836HN4XJFAw==,type:str] + lexi_password: ENC[AES256_GCM,data:J/FBiAJ+E68D,iv:IPLWnSZdI7/gls9vpSVV0IU/xgPTvLyhM+9g5HXt0xQ=,tag:dCnS6nOtMGhHOmK/zA0XQw==,type:str] + lexi_ssss_recovery_key: ENC[AES256_GCM,data:ZZkWp4iN79kviyBAvb2T7hWz/NVyo1mtP4E3GE97D1+v9tzT1CDPzqvKWg+jnE5APANroHO42jW/XtE=,iv:gZRhjCAksN9F65Sora1bnj6Hb7czV2CXxuK1VvE4tSY=,tag:bSbFOzrgWnfO9Yfkxidg9w==,type:str] + s3_access_key: ENC[AES256_GCM,data:bFs7IJa4MGcOO1dW730ixz/WEV4=,iv:GMJGZF1UQxKIogHQJa41K/V9IB1LFMk+iJEaiSVHd0c=,tag:Ln23zYajbXoo/4bpL3alnA==,type:str] + s3_secret_key: ENC[AES256_GCM,data:PIN8mj9wGuMo7yE4wU3k7fTm3P3Rtt9fVFGydICSFgif9cvWV3yPWQ==,iv:AREyk7sUcG+eYxKgZSaV9PLmn9el9VRtErY49yUOChE=,tag:5Wc65aahq2O/m50dPPNj+A==,type:str] + key_export_passphrase: ENC[AES256_GCM,data:b36fFcraIOQFU8yk0Q701Fhe4chbLJTlF/t3WuVJc/l74PZmhAkIz3itTFbHRfBXWrZgD8+SQAAM9RGHBKsF,iv:LjsTK7YBP3qnnrJ+ydP2JjdEW6vClRZ/zzl5yeJbxQM=,tag:qIeP341xoZQWT7G3vWMnxg==,type:str] + age_recipients: ENC[AES256_GCM,data:379jjM7R7VT+CYo46VhiSP9hFfpNawC/DG23XY5cK69V3WRnolul6/RfpEkbzG/mCa4+IldRBfBcGiF/0mU=,iv:bMkatG+qnJcjG1RCXUxgs7wTj4/FG06TU2GmezLWMRc=,tag:zC6UVXLyPzkX4c9UC6DALA==,type:str] sops: kms: [] gcp_kms: [] @@ -21,14 +22,14 @@ sops: - recipient: age1esjyg2qfy49awv0ptkzvpk425adczjr38m37w2mmcahzc4p8n54sll2nzh enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxUktNQmlTUk1RN2JicTla - RjVFb0V5amtIczVuRVhWTjFIQXdEUVJ3TVdzClJadDNEYStwQW02L3VkK0NuNmxr - TVRCdHRObXBBOUxPaHZ3ZnozbFIwVk0KLS0tIFZBVFRWOUtZaG9rTmZQbDBKVThr - RmxKbkZkanZkSTRyeWM4Rm9jcWhUWkUKzmGRfGP9LQT1ypQYb70+Sfls29m3B/Va - w2WG8BfC2L3Pt/eKalgcPdrqafZ0KWwn/YK6TFw2UDFaQhlq6CkAYw== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1VkJOK3lCV0p2UEZoLzI5 + Z0gwYStvbHQvR0tPYTNTQkliUllTdWxBWTFFCjU1d0tjWEJpbWlaYnBibGVWS0JN + WlowWW96TkJRKzFtV1puUjg3TzVZYWsKLS0tIDRmWThjL0pqZ2ZaaEduLzhnMitE + TVMwYzZyWTRPWXJJcXdvSDlUc3V2RVEK+gBDUWRYYfB58PHx2wuU/wT1vhAnqvUD + kwK74QfYOtW5eYIYqqQpXgsmywSUZX0KWV/oJB0nWSH87jDAnor1GA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-29T19:15:46Z" - mac: ENC[AES256_GCM,data:B0jvpT7VB0V9rkm1HEUHOLV9EcrVYDaP2Wzwjxg90QvsB5xkhPem6Wd3+jqNcWHnBsKG9XvE0skrzbGdK0ESBMeRUVBrmV9jCxWDPNRhkcjuRqvaxbyrr2C79tsEqWMkJmlUZy+1DsWQ/pyDourKHyt2nw1dZwOJUDexLXfiOsQ=,iv:fvQ+s3OpUrDitaOzJepQT0DoeZh2G8GZ5iOAi56Rz5U=,tag:TngETHbq43LZGX9oHiz4kg==,type:str] + lastmodified: "2026-04-04T00:17:18Z" + mac: ENC[AES256_GCM,data:n9o7hQbPlW5hwbCIQi+eWonq9FcXeDNQpycajNq7JUfJr65mehiUsW7E7lnyjEVeYzIvC2zPLQn3Aue7GESxUUpPaojiH6+KFYSHDuViAzr7crldORqqyi7E1CzT90gLYySy++YbiDIyk2XJ3wsCa8wREu/9x1i1BXcrKZeZidM=,iv:5CX6QY6gRk5YPNm1XaI5IRG6LimMSdf+0d2Y/l0wfOo=,tag:7xFCwKMMQTbbqu/xnT+I9g==,type:str] pgp: [] encrypted_regex: ^(apiKey|appUserPassword|otelUserPassword|harborAdminPassword|totpVaultKey|kimaiAppSecret|kimaiAdminPassword|GITHUB_CLIENT_ID|GITHUB_CLIENT_SECRET|GITHUB_PRIVATE_KEY|woosh|root_password|rspamd_password|pgdb_password|matrix_access_token|pgdb_remote_url|hmac_secret_key|adminPassword|adminEmail|jenkinsAdminEmail|securityRealm|gerrit.config|routing_key|DATABASE_URL|SMTP_PASSWORD|SECRET_KEY_BASE|admin_password|extraCommands|key|clickhouseDatabaseURL|databaseURL|client_id|client_secret|secret_key_base|otp_secret|private_key|public_key|primaryKey|deterministicKey|keyDerivationSalt|token|clientId|secretKey|installationId|installationKey|uriOverride|adminToken.value|password.value|sql_password|erlangCookie|AUTHENTICATION_PASSWORD|ROOM_API_SECRET_KEY|adminPassword|configPassword|adminUser|configUser|MAIL_PASSWORD|APP_KEY|api_key|api_secret|keys|livekit_key|livekit_secret|secret_key|admin_pass|admin_email|mariadbPassword|mariadbRootPassword|privateKey|data|stringData|PASSWD|password|pass|postgresPassword|smtp_auth_password|addresses|smtp_auth_username|authorization_credentials|postgresqlPassword|redminePassword|smtpPassword|registration_shared_secret|shared_secret|secret|admin_token|integrationKey|integration_key|rootPassword|adminPassword|adminUser|adminEmail|emailPassword|secretKey|appId|clientSecret|webhookSecret)$ version: 3.9.1