eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– Release-sighting alerts β€” the human-detection half of the supply-chain
4// bake window (docs/server-releases.md Β§ The bake window). The bake buys time
5// between a release appearing and it installing; this is what makes that time
6// useful: when the instance's own 6-hourly feed check sees its channel head
7// CHANGE, it pushes the space's admins. The alert is out-of-band from the
8// signing key β€” it fires on observation of the feed, not on the publisher's
9// cooperation β€” so an attacker who can sign releases cannot publish quietly.
10//
11// Payload safety: channel names, versions, semvers, and hold lengths are
12// public feed data (branding-class, exactly the changelog banner's argument
13// in announce.go) β€” never user content, never a key.
14
15import (
16 "encoding/json"
17 "fmt"
18 "log"
19)
20
21// releaseSeenKey is the per-channel last-sighted-head watermark, config-kept
22// like changelog_announced: first sighting on a channel baselines silently (a
23// fresh space isn't greeted with old news), later sightings announce the diff.
24const releaseSeenKey = "release_seen_heads"
25
26// releaseAlertText builds the admin notification for a newly-sighted channel
27// head. ok=false when the sighting shouldn't alert: edge (no hold β€” its
28// riders are the operators doing the publishing, and the post-install
29// changelog banner covers arrival).
30func releaseAlertText(group, channel string, e releaseEntry, autoUpdate bool, major string) (title, body string, ok bool) {
31 label := e.Version
32 if e.Semver != "" {
33 label = "v" + e.Semver + " (" + e.Version + ")"
34 }
35 switch {
36 case majorHold(major, e) != "":
37 body = fmt.Sprintf("%s appeared on the %s channel, but it crosses the major version this space stays on β€” nothing installs until you raise that in the admin console.", label, channel)
38 case bakeHours(channel) == 0:
39 return "", "", false
40 case !autoUpdate:
41 body = fmt.Sprintf("%s appeared on the %s channel. Automatic updates are off for this space, so it's yours to apply from the admin console.", label, channel)
42 default:
43 body = fmt.Sprintf("%s appeared on the %s channel. It installs automatically after the safety hold (about %d hours) β€” check the admin console if you weren't expecting a release.", label, channel, bakeHours(channel))
44 }
45 return "Server update on the way β€” " + group, body, true
46}
47
48// noteReleaseSightings runs after every verified feed fetch (checkForUpdate).
49// Returns (alerted, sent) for tests β€” whether this sighting warranted an
50// alert, and how many push sends succeeded.
51func (s *Server) noteReleaseSightings(m *releaseManifest) (alerted bool, sent int) {
52 ch := s.db.updateChannel()
53 e, ok := m.Channels[ch]
54 if !ok {
55 return false, 0
56 }
57 raw, had, _ := s.db.getConfig(releaseSeenKey)
58 seen := map[string]string{}
59 if had {
60 if err := json.Unmarshal([]byte(raw), &seen); err != nil {
61 // A corrupt watermark re-baselines rather than announcing a backlog.
62 log.Printf("release-alert: bad %s config (%v) β€” re-baselining", releaseSeenKey, err)
63 seen = map[string]string{}
64 }
65 }
66 last := seen[ch]
67 if last == e.Version {
68 return false, 0
69 }
70 // Advance the watermark before pushing (at-most-once, like the changelog):
71 // a crash mid-announce must not replay the alert on every later check.
72 seen[ch] = e.Version
73 if b, err := json.Marshal(seen); err == nil {
74 if err := s.db.setConfig(releaseSeenKey, string(b)); err != nil {
75 log.Printf("release-alert: save watermark: %v", err)
76 }
77 }
78 if last == "" {
79 return false, 0 // first sighting on this channel β€” baseline silently
80 }
81 if e.Version == buildVersion {
82 // The head is what we're already running (we just updated to it, or a
83 // pull-back landed on us) β€” nothing is "on the way".
84 return false, 0
85 }
86 title, body, ok := releaseAlertText(s.db.getGroupName(), ch, e, s.db.updateAuto(), s.db.updateMajor())
87 if !ok {
88 return false, 0
89 }
90 admins, err := s.db.adminUserIDs()
91 if err != nil || len(admins) == 0 {
92 log.Printf("release-alert: %s head moved to %s, no admins to notify (err=%v)", ch, e.Version, err)
93 return true, 0
94 }
95 recipients := make(map[string]bool, len(admins))
96 for _, id := range admins {
97 recipients[id] = true
98 }
99 payload, _ := json.Marshal(map[string]any{
100 "release": true,
101 "title": title,
102 "body": body,
103 })
104 // skipActive=false: like the changelog banner, this is the one signal an
105 // admin gets with the app closed β€” and rare enough to always show.
106 sent = s.sendToRecipients(payload, "release-alert", "", false, recipients)
107 sent += s.apnsAnnounce(title, body, recipients)
108 log.Printf("release-alert: %s head %s announced to %d admin subscription(s)", ch, e.Version, sent)
109 return true, sent
110}
111