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:
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