Skip to content
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ func main() {
alerts.Default.Muted = service.MyService.System().DockerUpdateMuted
alerts.Default.LastDockerRun = lastDockerRun
service.MyService.System().OnDockerUpdateQueued(alerts.Default.WatchDockerUpdate)
// An operating system whose security updates are about to end, or have ended, is told once for
// each state: the alerts look at what the dashboard's badge reads, five minutes after the start
// and every day.
alerts.Default.OSSupport = service.MyService.System().GetSystemOS
go alerts.Default.Run(context.Background())
// Automatic updates start the button's own update, read the button's own
// version.json, and settle an update that restarted the core before checking.
Expand Down
77 changes: 62 additions & 15 deletions pkg/alerts/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
// raise and resolve alike. And when it ends the owner is told once, whether or
// not the dashboard is open, even across a restart of the core: the nonce of
// the last run told is kept in docker-update.notified.
//
// And when the operating system's security updates are about to end, or have ended,
// the owner is told once for each state of each release, from a look every day: the
// last one told is kept in os-support.notified, written once a message has gone out,
// so that a state is looked at again, every day, until a channel has taken it.
package alerts

import (
Expand All @@ -28,10 +33,12 @@ import (
"slices"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/ReCasaOS/CasaOS-Common/utils/logger"
"github.com/ReCasaOS/CasaOS/pkg/config"
"github.com/ReCasaOS/CasaOS/pkg/ossupport"
"github.com/ReCasaOS/CasaOS/pkg/utils/version"
"github.com/nicholas-fedor/shoutrrr/pkg/router"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
Expand Down Expand Up @@ -99,6 +106,9 @@ type Hub struct {
Muted func() bool
// LastDockerRun is the last run of the Docker update, from the run's log and systemd.
LastDockerRun func() DockerRun
// OSSupport is the operating system and how long its security updates last, as the service says
// it: only the last two states that end it are told.
OSSupport func() ossupport.Report

modifying sync.Mutex // serialises Update's read-modify-writes of alerts.json
mu sync.Mutex // guards sent and lastFailure
Expand All @@ -108,6 +118,9 @@ type Hub struct {
dockerMu sync.Mutex // serialises dockerEnded
dockerNotified string // the nonce of the last Docker update told, guarded by dockerMu

osMu sync.Mutex // serialises checkOSSupport
osNotified string // the last state of the OS's support told, guarded by osMu

// Read and written by Run's goroutine only.
seen []disk // the disks of the previous poll, nil before the first
releaseChecked time.Time // the last look for a newer release
Expand All @@ -120,7 +133,7 @@ type Hub struct {
var Default = New("/")

// New keeps alerts.json under root and sends through Shoutrrr. Address,
// Latest, AutoUpdates, Muted and LastDockerRun are left to the caller.
// Latest, AutoUpdates, Muted, LastDockerRun and OSSupport are left to the caller.
func New(root string) *Hub {
return &Hub{
Root: root,
Expand All @@ -134,16 +147,19 @@ func New(root string) *Hub {
AutoUpdates: func() bool { return true },
Muted: func() bool { return false },
LastDockerRun: func() DockerRun { return DockerRun{} },
OSSupport: func() ossupport.Report { return ossupport.Report{} },
sent: map[string]*record{},
}
}

// Run follows the message bus until ctx is done, tells a Docker update that ended
// or is going on, and looks at the disks and for a newer release five minutes
// after the start, then every hour.
// or is going on, tells the operating system's support when it is ending or ended
// (five minutes after the start, then every day), and looks at the disks and for a
// newer release five minutes after the start, then every hour.
func (h *Hub) Run(ctx context.Context) {
go h.followBus(ctx)
go h.watchDocker(ctx)
go h.watchOSSupport(ctx)
timer := time.NewTimer(5 * time.Minute)
defer timer.Stop()
for {
Expand All @@ -163,6 +179,14 @@ func (h *Hub) Run(ctx context.Context) {
// the next message says how often it came back. It reports whether a is now
// on record as sent.
func (h *Hub) raise(a alert) bool {
return h.raiseAs(a, false)
}

// raiseAs is raise, and with wait it sends now and waits for the channels, and then
// reports whether one of them took the message: when none did, a is not on record, so
// that the caller can try again. A caller that tells a condition once, for good, needs
// that; the others fire and forget.
func (h *Hub) raiseAs(a alert, wait bool) bool {
c := h.load()
if len(c.Channels) == 0 || !c.Categories[a.category] {
return false
Expand All @@ -181,8 +205,21 @@ func (h *Hub) raise(a alert) bool {
if previous != nil && previous.repeats > 0 {
sentence += fmt.Sprintf(" It happened %d times since %s.", previous.repeats+1, previous.at.Format("15:04"))
}
h.deliver(c.Channels, a.category, sentence)
return true
if !wait {
h.deliver(c.Channels, a.category, sentence)
return true
}
if h.deliverAll(c.Channels, a.category, sentence) {
return true
}
h.mu.Lock()
if previous == nil {
delete(h.sent, a.key)
} else {
h.sent[a.key] = previous
}
h.mu.Unlock()
return false
}

// resolve sends one "resolved" message for key, only if its alert was sent,
Expand All @@ -209,19 +246,29 @@ func (h *Hub) deliver(channels []Channel, category, sentence string) {
h.sending.Add(1)
go func() {
defer h.sending.Done()
title, message := h.compose(category, sentence)
var each sync.WaitGroup
for _, channel := range channels {
each.Add(1)
go func() {
defer each.Done()
_ = h.sendTo(channel, title, message)
}()
}
each.Wait()
h.deliverAll(channels, category, sentence)
}()
}

// deliverAll sends a message to every channel, each on its own, and waits for them: it
// reports whether at least one took it.
func (h *Hub) deliverAll(channels []Channel, category, sentence string) bool {
title, message := h.compose(category, sentence)
var each sync.WaitGroup
var accepted atomic.Bool
for _, channel := range channels {
each.Add(1)
go func() {
defer each.Done()
if h.sendTo(channel, title, message) == nil {
accepted.Store(true)
}
}()
}
each.Wait()
return accepted.Load()
}

// compose is the title, "ReCasaOS · <hostname> · <category>", and the message:
// the sentence, then the dashboard's address when the core knows it.
func (h *Hub) compose(category, sentence string) (title, message string) {
Expand Down
116 changes: 116 additions & 0 deletions pkg/alerts/os_support.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package alerts

import (
"context"
"os"
"path/filepath"
"strings"
"time"

"github.com/ReCasaOS/CasaOS-Common/utils/logger"
"github.com/ReCasaOS/CasaOS/pkg/ossupport"
"github.com/ReCasaOS/CasaOS/pkg/utils/file"
"go.uber.org/zap"
)

// OSSupportNotifiedFile holds the last state of the operating system's support that was told to the
// owner, "<id>-<version_id>-<state>". It is the core's only memory of it: a restart changes nothing.
const OSSupportNotifiedFile = "/var/lib/casaos/os-support.notified"

var (
// osSupportFirst is how long after the start the first look comes: the same five minutes as the
// disks', so that the network is up when a message that is told once is sent. osSupportEvery
// is how often it looks again, so that a box that crosses its date is told. Vars, so tests can
// shorten them.
osSupportFirst = 5 * time.Minute
osSupportEvery = 24 * time.Hour
)

// watchOSSupport tells the owner when the operating system's security support is ending or has
// ended, once for each state of each release: it looks five minutes after the start, then every day.
func (h *Hub) watchOSSupport(ctx context.Context) {
timer := time.NewTimer(osSupportFirst)
defer timer.Stop()
for {
select {
case <-timer.C:
case <-ctx.Done():
return
}
h.checkOSSupport()
timer.Reset(osSupportEvery)
}
}

// checkOSSupport sends the one message of a state of support that is not told yet, and nothing for
// "supported" or "unknown". The state is told once: the last one told is in memory and in the marker
// file. Unlike a Docker run, which is over, a state of support lasts for months, and a warning that
// was lost is worse than one that comes twice: so the marker is written only after a message went
// out. With no channel, with the Updates category off, or when every channel failed, nothing is
// written and the next look (a day later) tries again. A crash between the send and the marker
// may tell the state once more. Any number of looks may run at once: one of them tells it.
func (h *Hub) checkOSSupport() {
report := h.OSSupport()
state := report.Support.State
if state != ossupport.EndingSoon && state != ossupport.SecurityEnded {
return
}
key := report.ID + "-" + report.VersionID + "-" + state

h.osMu.Lock()
defer h.osMu.Unlock()
if key == h.osNotified {
return
}
path := filepath.Join(h.Root, OSSupportNotifiedFile)
if data, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(data)) == key {
h.osNotified = key
return
}
// one key per state: the six hours an alert stays quiet are for repeats, not for the next state
if !h.raiseAs(alert{key: "os-support:" + key, category: Updates, sentence: osSupportSentence(report)}, true) {
return
}
// ponytail: without the file (an unwritable state directory) a restart tells the state again.
h.osNotified = key
if err := file.WriteFileAtomic(path, []byte(key+"\n")); err != nil {
logger.Error("alerts: cannot remember that the support of the operating system was told", zap.Error(err))
}
}

// osSupportSentence is what the owner reads: what ends or ended and when, then what the product
// does not do yet.
func osSupportSentence(report ossupport.Report) string {
name, kind := "", ""
switch report.ID {
case "debian":
name, kind = "Debian", "Debian"
case "ubuntu":
name, kind = "Ubuntu", "standard"
}
date := osSupportDate(report.Support)
subject := name + " " + report.VersionID
var sentence string
switch {
case report.Support.State == ossupport.SecurityEnded:
sentence = subject + " no longer receives " + kind + " security updates since " + date + "."
case report.Support.Precision == ossupport.PrecisionMonth:
sentence = subject + " stops receiving " + kind + " security updates " + date + "."
default:
sentence = subject + " stops receiving " + kind + " security updates on " + date + "."
}
return sentence + " ReCasaOS cannot upgrade the operating system for you yet: see the dashboard."
}

// osSupportDate is the day in words, "31 August 2026", or the month with "around" for a date the
// vendor gives only the month of: "around May 2027".
func osSupportDate(support ossupport.Support) string {
end, err := time.Parse("2006-01-02", support.Ends)
if err != nil {
return support.Ends
}
if support.Precision == ossupport.PrecisionMonth {
return "around " + end.Format("January 2006")
}
return end.Format("2 January 2006")
}
Loading
Loading