cluster

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

commit c9dc71238cd7dc266f80017c7ba73d24925d51df
parent d3d8b45d079efde461b7a2ce3f11a5b42492a275
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Sat,  4 Apr 2026 11:07:06 +0200

fix more issues on updating

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/main.go | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mapps/talos_cluster/matrix-backup/secret.yaml | 31++++++++++++++++---------------
3 files changed, 112 insertions(+), 25 deletions(-)

diff --git a/apps/talos_cluster/matrix-backup/backup-cronjob.yaml b/apps/talos_cluster/matrix-backup/backup-cronjob.yaml @@ -85,6 +85,11 @@ spec: secretKeyRef: name: matrix-backup-secret key: age_recipients + - name: AGE_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: matrix-backup-secret + key: age_private_key volumeMounts: - name: data mountPath: /data diff --git a/apps/talos_cluster/matrix-backup/backup-tool/main.go b/apps/talos_cluster/matrix-backup/backup-tool/main.go @@ -92,6 +92,11 @@ var ( // 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 { @@ -123,8 +128,25 @@ func initAgeRecipients(s string) error { 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 +// Age encryption / decryption // ───────────────────────────────────────────────────────────────────────────── // ageEncrypt encrypts data for all ageRecipients and returns the ciphertext. @@ -143,6 +165,24 @@ func ageEncrypt(data []byte) ([]byte, error) { 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) @@ -938,10 +978,25 @@ func fetchRoomNameFromState(ctx context.Context, client *mautrix.Client, roomID // 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 { + existing[r.RoomID] = r + } + } + } + dmRooms := getDMRooms(syncResp) - var rooms []roomEntry for roomID, joinedRoom := range syncResp.Rooms.Join { var name, canonicalAlias string var aliases []string @@ -950,14 +1005,21 @@ func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix 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 != nil { + if c := ev.Content.AsRoomName(); c.Name != "" { name = c.Name } case event.StateCanonicalAlias: - if c := ev.Content.AsCanonicalAlias(); c != nil { + if c := ev.Content.AsCanonicalAlias(); c.Alias != "" { canonicalAlias = string(c.Alias) for _, a := range c.AltAliases { aliases = append(aliases, string(a)) @@ -976,6 +1038,7 @@ func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix } } } + joinedCount, invitedCount := 0, 0 if joinedRoom.Summary.JoinedMemberCount != nil { joinedCount = *joinedRoom.Summary.JoinedMemberCount @@ -983,34 +1046,47 @@ func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix if joinedRoom.Summary.InvitedMemberCount != nil { invitedCount = *joinedRoom.Summary.InvitedMemberCount } + if name == "" { name = calcRoomName(joinedRoom.State.Events, joinedRoom.Summary.Heroes, joinedCount, invitedCount, client.UserID) } if name == "" { - // Sync only sends changed state — large/public rooms may not include - // m.room.name or alias in the delta. Fall back to a direct state fetch. + // 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) } - mc := joinedCount - rooms = append(rooms, roomEntry{ + + existing[string(roomID)] = roomEntry{ RoomID: string(roomID), Name: name, Type: rtype, Aliases: aliases, CanonicalAlias: canonicalAlias, - MemberCount: mc, + MemberCount: joinedCount, Encrypted: encrypted, - }) + } 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 } @@ -1498,6 +1574,11 @@ func main() { os.Exit(1) } + if err := initAgeIdentity(os.Getenv("AGE_PRIVATE_KEY")); err != nil { + slog.Error("Failed to init age identity", "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,14 +5,15 @@ metadata: namespace: matrix-backup type: Opaque stringData: - 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] + mtrnord_password: ENC[AES256_GCM,data:1UWugPLDRWC2,iv:jVIR31haLjgoqhqhodx9AD29OgIAy8A3MB5cm6WVP2Y=,tag:ZWPpxiJrrOXapb7MQNtYug==,type:str] + mtrnord_ssss_recovery_key: ENC[AES256_GCM,data:ebumKWNNStICm8OT8CijbvWws5gbznbf/3HCjJlBRv1itKzpSUJANvLnw391+YxxYcghdYYZAaQ2qm8=,iv:LXJYjF/Uuk4dxxZYffzU+HSHqM/u2GDlWKEI3w/9nm8=,tag:XVP4p98gu7cp9WvdipQV6w==,type:str] + lexi_password: ENC[AES256_GCM,data:TOLcQ7bw1QDM,iv:R38xRpWfhtT8jxf0RMynfbsGipOApgz1XTQIYdcg2kk=,tag:lU0SaDRmOtfuRlyasY4UjQ==,type:str] + lexi_ssss_recovery_key: ENC[AES256_GCM,data:f88XCoUYNao/96mKxQHuvQveUizoV0FURg4iJ7yrdQOtQhPI9raj1xNHza3jN1mvuSu/Y2kriq0cryM=,iv:GW+2c/sQ2X5COcRXxxKFgUmys1QnSEtMXJid1c9ku4E=,tag:0PKUWSIHgGgD5KDfxIMCRA==,type:str] + s3_access_key: ENC[AES256_GCM,data:2jVHZW/cYXepY2V1fqwGbTB+AI8=,iv:EvlUA2WB7X4+45MXEtgeXlqbOBudZSmXj1XOWQ0uy0Y=,tag:UrVZkYXjEsry+H3jHaxN/w==,type:str] + s3_secret_key: ENC[AES256_GCM,data:G86JCdmKgQpfG60wjG5b6pG3jQ/gVqpY7sIaapttHnsJqkYg8D6biQ==,iv:4ca7cARSXBbzmc3J28UBbXxiBh7RvNT8jN1ol3e/OFk=,tag:8SrM04aMmdr1i5IlOyu8EA==,type:str] + key_export_passphrase: ENC[AES256_GCM,data:1xGg/YhrfO/S2mGCgrxOm6uzSUS30s/NGL2dWrivh7kTU7iXQhLmQy703viGqK7XyxNBOycKKsBd2Un47ICc,iv:8milwu03T7UjuLDLYAOkefJ16IF2UM7JJPVbc6jCNGM=,tag:FSTWDOAL8WeBBwmo1eJ24Q==,type:str] + age_recipients: ENC[AES256_GCM,data:wQqXkhJTlFv47HiKeZipkblw7DsxB0M8ok9J4bb+mSDmtswK0SLM67zoz6ZTsMGx+hpK4ippupI9G/+JNGU=,iv:BM68hlCGy0xZcutM50+iCgRIP7AIxmLQ/1evmu4cM3s=,tag:uNieiriNqJ1u5/Gv0O+p5A==,type:str] + age_private_key: ENC[AES256_GCM,data:dWyyiEqA+ALVuDYIZiPUnt+rTPWLwussugn5a/arbdEnFTzEBqr3fGhCI9cge/Rh0pUmmO/N50NzBgTnTaoyogLJNPcd/sUkw8Q=,iv:4mUjveSSz4jvTfY/UB4DxvFzqtbngmyfRxr9eNsmptw=,tag:yJYvNN9qHZRWjHWIlOqjwg==,type:str] sops: kms: [] gcp_kms: [] @@ -22,14 +23,14 @@ sops: - recipient: age1esjyg2qfy49awv0ptkzvpk425adczjr38m37w2mmcahzc4p8n54sll2nzh enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1VkJOK3lCV0p2UEZoLzI5 - Z0gwYStvbHQvR0tPYTNTQkliUllTdWxBWTFFCjU1d0tjWEJpbWlaYnBibGVWS0JN - WlowWW96TkJRKzFtV1puUjg3TzVZYWsKLS0tIDRmWThjL0pqZ2ZaaEduLzhnMitE - TVMwYzZyWTRPWXJJcXdvSDlUc3V2RVEK+gBDUWRYYfB58PHx2wuU/wT1vhAnqvUD - kwK74QfYOtW5eYIYqqQpXgsmywSUZX0KWV/oJB0nWSH87jDAnor1GA== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqM0pUaG55UTNobVdCeVdP + T0cyTUlWRTVFS0l3Y3BmcnFLaVFDSjZqb1gwCjNLVXRDd3BqQ29KdWQxRFArTDlI + RVhzNVp3c2gzVGMyVXp4NU9zRXRNaEkKLS0tIFRvRVlaVlpLa3lEZ2gyZjFoVWN3 + MnJHNDZhRGRRWHdZVm1sVkljbFh5K0kK4von+bFCRbArOha4jM3hQIuRT20Q1AJL + FDtgvEvDZlsqMn/MKHDoVw6E4Cqf64R7ulwgxrGE0Xx+KA8sIJ+bWQ== -----END AGE ENCRYPTED FILE----- - 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] + lastmodified: "2026-04-04T09:06:54Z" + mac: ENC[AES256_GCM,data:kp9cYmG1xH7qsWOFLI6LvzAhjnxxE2MGUGS7NwczER2tkAh7L6WzVm3WHxQznEtHaez65uMRFspLLpkN/H/RETGbVZ6O49egnw+wDDjfOIcr7zdllrkBliaAt+mwJeF/3NYho1nj9axKREn25DeG5PeeRRTSJsZN0BE/8zrcaVU=,iv:EE1TkZDQ/cyAWYa5pBzSYggcSbeYUqmPrjCc4G5kbGM=,tag:RseOCxsH4Ni7EhhJBee0xQ==,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