diff --git a/main.go b/main.go index 5baae1e1..88503a70 100644 --- a/main.go +++ b/main.go @@ -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. diff --git a/pkg/alerts/alerts.go b/pkg/alerts/alerts.go index f606be97..f957a1d2 100644 --- a/pkg/alerts/alerts.go +++ b/pkg/alerts/alerts.go @@ -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 ( @@ -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" @@ -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 @@ -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 @@ -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, @@ -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 { @@ -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 @@ -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, @@ -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 · · ", 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) { diff --git a/pkg/alerts/os_support.go b/pkg/alerts/os_support.go new file mode 100644 index 00000000..1ce4fcb8 --- /dev/null +++ b/pkg/alerts/os_support.go @@ -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, "--". 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") +} diff --git a/pkg/alerts/os_support_test.go b/pkg/alerts/os_support_test.go new file mode 100644 index 00000000..8fb4389d --- /dev/null +++ b/pkg/alerts/os_support_test.go @@ -0,0 +1,636 @@ +package alerts + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/ossupport" +) + +const handoff = " ReCasaOS cannot upgrade the operating system for you yet: see the dashboard." + +var ( + debian11 = map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye"} + debian12 = map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"} + ubuntu20 = map[string]string{"ID": "ubuntu", "VERSION_ID": "20.04", "VERSION_CODENAME": "focal"} + ubuntu22 = map[string]string{"ID": "ubuntu", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"} + mint = map[string]string{"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia"} +) + +// osFrom is the box of a release at the day of the hub's clock: the answer of the service, with the +// real table. +func osFrom(h *Hub, release map[string]string, now *time.Time) { + h.OSSupport = func() ossupport.Report { return ossupport.Describe(release, "amd64", *now, nil) } +} + +// osMarker is what the marker file holds, "" when there is none. +func osMarker(h *Hub) string { + data, err := os.ReadFile(filepath.Join(h.Root, OSSupportNotifiedFile)) + if err != nil { + return "" + } + return string(data) +} + +// onDay is the hub's clock at midnight of a day. +func onDay(t *testing.T, now *time.Time, day string) { + t.Helper() + d, err := time.Parse("2006-01-02", day) + if err != nil { + t.Fatal(err) + } + *now = d +} + +func TestTheSupportOfTheOperatingSystemIsToldInWords(t *testing.T) { + for name, tc := range map[string]struct { + release map[string]string + day string + want string + }{ + "Debian 11 ended": {debian11, "2026-10-10", + "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, + "Debian 11 ended, a year later": {debian11, "2027-10-10", + "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, + // what ended stays ended when the table is old: the owner is still told + "Debian 11 ended, the table stale": {debian11, "2028-05-01", + "Debian 11 no longer receives Debian security updates since 31 August 2026." + handoff}, + "Debian 12 ending": {debian12, "2028-01-15", + "Debian 12 stops receiving Debian security updates on 30 June 2028." + handoff}, + "Ubuntu 22.04 ending, to the month": {ubuntu22, "2027-01-15", + "Ubuntu 22.04 stops receiving standard security updates around May 2027." + handoff}, + "Ubuntu 22.04 ended, to the month": {ubuntu22, "2027-06-02", + "Ubuntu 22.04 no longer receives standard security updates since around May 2027." + handoff}, + "Ubuntu 20.04 ended": {ubuntu20, "2026-10-10", + "Ubuntu 20.04 no longer receives standard security updates since around May 2025." + handoff}, + } { + t.Run(name, func(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, tc.release, now) + onDay(t, now, tc.day) + + h.checkOSSupport() + h.sending.Wait() + + if got := o.texts(phone); len(got) != 1 || got[0] != tc.want+"\nhttp://192.168.1.20" { + t.Fatalf("sent %q, want %q", got, tc.want) + } + if o.sent[0].title != "ReCasaOS · box · updates" { + t.Fatalf("title = %q", o.sent[0].title) + } + }) + } +} + +// Nothing for a release that is supported, for one that is not known, and for a hub that was never +// told how to look. +func TestNothingIsToldForASupportedOrUnknownSystem(t *testing.T) { + for name, tc := range map[string]struct { + release map[string]string + day string + }{ + "Debian 12, supported": {debian12, "2026-10-10"}, + "Ubuntu 22.04, supported": {ubuntu22, "2026-12-01"}, + "a Mint, which is not known": {mint, "2026-10-10"}, + "nothing read": {nil, "2026-10-10"}, + "a table gone stale, a Debian 12": {debian12, "2028-05-01"}, + } { + t.Run(name, func(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, tc.release, now) + onDay(t, now, tc.day) + + h.checkOSSupport() + h.sending.Wait() + + if len(o.sent) != 0 || len(h.sent) != 0 || osMarker(h) != "" { + t.Fatalf("sent %+v, on record %v, marker %q; want nothing", o.sent, h.sent, osMarker(h)) + } + }) + } + h, o, _ := newHub(t) + h.checkOSSupport() // the hub New made, with no one to ask + h.sending.Wait() + if len(o.sent) != 0 { + t.Fatalf("sent %+v", o.sent) + } +} + +// A Raspberry Pi OS, a Proxmox and the like say Debian 11, whose security updates have ended, and are +// told nothing: they are not known, and Debian's dates are not theirs to be told. +func TestNothingIsToldForAVariant(t *testing.T) { + for variant, path := range map[string]string{ + "raspberrypi": "/etc/rpi-issue", "armbian": "/etc/armbian-release", "dietpi": "/boot/dietpi.txt", + "proxmox": "/usr/bin/pveversion", "openmediavault": "/etc/openmediavault", + } { + t.Run(variant, func(t *testing.T) { + h, o, now := newHub(t) + onDay(t, now, "2026-10-10") + h.OSSupport = func() ossupport.Report { + return ossupport.Describe(debian11, "arm64", *now, func(p string) bool { return p == path }) + } + + h.checkOSSupport() + h.sending.Wait() + + if len(o.sent) != 0 || osMarker(h) != "" { + t.Fatalf("sent %+v, marker %q; want nothing", o.sent, osMarker(h)) + } + }) + } +} + +// One message, to each channel, for a state, however often it is looked at. +func TestAStateIsToldOnce(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + + for range 5 { + h.checkOSSupport() + } + h.sending.Wait() + + if len(o.texts(phone)) != 1 || len(o.texts(mail)) != 1 { + t.Fatalf("sent %+v, want one message for each of the two channels", o.sent) + } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker = %q", got) + } + if _, kept := h.sent["os-support:debian-11-security_ended"]; !kept { + t.Fatalf("not on record under os-support:debian-11-security_ended: %v", h.sent) + } +} + +// Told again only when the state changes: Ubuntu 22.04 goes from supported, to ending, to ended, a +// day, and the six quiet hours of an alert already sent do not hide the second state. +func TestAStateIsToldAgainOnlyWhenItChanges(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, ubuntu22, now) + + step := func(day string) { + onDay(t, now, day) + h.checkOSSupport() + h.sending.Wait() + } + step("2026-10-10") + if len(o.sent) != 0 { + t.Fatalf("supported: sent %+v", o.sent) + } + step("2026-12-03") + step("2026-12-04") + step("2027-05-31") + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Ubuntu 22.04 stops receiving standard security updates around May 2027.") { + t.Fatalf("ending: sent %q", got) + } + if got := osMarker(h); got != "ubuntu-22.04-ending_soon\n" { + t.Fatalf("marker = %q", got) + } + + // the day after, a few hours later in the same six: the state is new + step("2027-06-02") + step("2027-06-03") + got := o.texts(phone) + if len(got) != 2 || !strings.HasPrefix(got[1], "Ubuntu 22.04 no longer receives standard security updates since around May 2027.") { + t.Fatalf("ended: sent %q", got) + } + if got := osMarker(h); got != "ubuntu-22.04-security_ended\n" { + t.Fatalf("marker = %q", got) + } +} + +// A box that was told, restarted, is not told again; a state that came while it was down is told once. +func TestAStateIsToldOnceAcrossARestart(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, ubuntu22, now) + onDay(t, now, "2027-01-15") + h.checkOSSupport() + h.sending.Wait() + if len(o.texts(phone)) != 1 { + t.Fatalf("sent %q", o.texts(phone)) + } + if entries, _ := os.ReadDir(filepath.Dir(filepath.Join(h.Root, OSSupportNotifiedFile))); len(entries) != 2 { + t.Errorf("the state directory holds %v, want alerts.json and the marker only: the write leaves nothing behind", entries) + } + + again := restarted(h, o) + osFrom(again, ubuntu22, now) + again.checkOSSupport() + again.sending.Wait() + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("after the restart the same state was told again: %q", got) + } + + // it ended while the core was down + onDay(t, now, "2027-06-10") + again = restarted(h, o) + osFrom(again, ubuntu22, now) + again.checkOSSupport() + again.sending.Wait() + if got := o.texts(phone); len(got) != 2 || !strings.Contains(got[1], "no longer receives") { + t.Fatalf("sent %q, want the new state told once", got) + } + + // and another restart tells nothing + third := restarted(h, o) + osFrom(third, ubuntu22, now) + third.checkOSSupport() + third.sending.Wait() + if got := o.texts(phone); len(got) != 2 { + t.Fatalf("sent %q", got) + } +} + +// What was told stays told when the box is upgraded: a Debian 12 that was ending is told again when +// it is a Debian 13 that is ending, because that is another release. +func TestAnotherReleaseIsAnotherMessage(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian12, now) + onDay(t, now, "2028-02-01") + h.checkOSSupport() + h.sending.Wait() + // a Debian 13 would be told ending in 2030; the keys are what differ + h.OSSupport = func() ossupport.Report { + return ossupport.Report{ + Identity: ossupport.Identity{ID: "debian", VersionID: "13", Known: true}, + Support: ossupport.Support{State: ossupport.EndingSoon, Ends: "2030-06-30", Precision: ossupport.PrecisionDay}, + } + } + h.checkOSSupport() + h.sending.Wait() + got := o.texts(phone) + if len(got) != 2 || !strings.HasPrefix(got[0], "Debian 12 stops") || !strings.HasPrefix(got[1], "Debian 13 stops receiving Debian security updates on 30 June 2030.") { + t.Fatalf("sent %q", got) + } +} + +// A marker file that is garbage is no state: it is told, and the file is mended. +func TestAMarkerThatIsGarbageDoesNotSilenceAState(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + if err := os.WriteFile(filepath.Join(h.Root, OSSupportNotifiedFile), []byte("\x00\x00 not a state"), 0o600); err != nil { + t.Fatal(err) + } + + h.checkOSSupport() + h.sending.Wait() + + if len(o.texts(phone)) != 1 || osMarker(h) != "debian-11-security_ended\n" { + t.Fatalf("sent %q, marker %q", o.texts(phone), osMarker(h)) + } +} + +// The marker is written once the message went out, and not before: a state that lasts for months +// must not be spent on a message that never left. Each send looks for the marker. +func TestTheMarkerIsWrittenOnceTheMessageWentOut(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + // the channels are sent to from their own goroutines + var mu sync.Mutex + var seen []string + h.Send = func(rawURL, title, text string) error { + marker := osMarker(h) + mu.Lock() + seen = append(seen, marker) + mu.Unlock() + return o.send(rawURL, title, text) + } + + h.checkOSSupport() + h.sending.Wait() + + mu.Lock() + defer mu.Unlock() + if len(o.texts(phone)) != 1 || len(seen) != 2 { + t.Fatalf("sent %q, the channels were sent to %d times", o.texts(phone), len(seen)) + } + for _, marker := range seen { + if marker != "" { + t.Fatalf("while the message was sent the marker was %q, want it not written yet", marker) + } + } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker after the send = %q", got) + } +} + +// The marker is replaced, not written over: the old file, which anything holding it open still has, +// is whole until the new one is renamed over it. A second name of the old file (a hard link) shows +// it: it keeps the old state, and the path has the new one. +func TestTheMarkerIsReplacedAtomically(t *testing.T) { + h, _, now := newHub(t) + osFrom(h, ubuntu22, now) + onDay(t, now, "2027-01-15") + h.checkOSSupport() + h.sending.Wait() + + path := filepath.Join(h.Root, OSSupportNotifiedFile) + old := path + ".old" + if err := os.Link(path, old); err != nil { + t.Skipf("no hard links here: %v", err) + } + onDay(t, now, "2027-06-10") + h.checkOSSupport() + h.sending.Wait() + + if data, _ := os.ReadFile(old); string(data) != "ubuntu-22.04-ending_soon\n" { + t.Fatalf("the old file now holds %q: the marker was written in place, not replaced", data) + } + if got := osMarker(h); got != "ubuntu-22.04-security_ended\n" { + t.Fatalf("marker = %q", got) + } +} + +// Two looks at once (the daily one, and whoever else) must not both tell a state. The clock jumps a +// day at each look, so that only the state stands between them and a second message. +func TestRacingLooksSendOnce(t *testing.T) { + h, o, _ := newHub(t) + h.OSSupport = func() ossupport.Report { + return ossupport.Describe(debian11, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), nil) + } + var looks atomic.Int64 + base := time.Date(2026, 9, 25, 6, 0, 0, 0, time.UTC) + h.Now = func() time.Time { return base.Add(time.Duration(looks.Add(1)) * 24 * time.Hour) } + start := make(chan struct{}) + var all sync.WaitGroup + for range 32 { + all.Add(1) + go func() { + defer all.Done() + <-start + h.checkOSSupport() + }() + } + close(start) + all.Wait() + h.sending.Wait() + + if len(o.texts(phone)) != 1 || len(o.texts(mail)) != 1 { + t.Fatalf("sent %+v, want the state told once to each channel", o.sent) + } +} + +// A state that lasts is not spent on a box that cannot tell it. With no channel (the default), or +// with the Updates category off, nothing is sent and nothing is written; the owner who adds a +// channel, or turns the category on, next week is told on the next look. +func TestAStateIsNotSpentWithNoChannelOrWithUpdatesOff(t *testing.T) { + everything := map[string]bool{Backups: true, Disks: true, Updates: true, Apps: true} + noUpdates := map[string]bool{Backups: true, Disks: true, Updates: false, Apps: true} + for name, tc := range map[string]struct { + before Config + after Config + }{ + "no channel, then one": { + Config{Categories: everything, DiskThreshold: 90}, + Config{Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, Categories: everything, DiskThreshold: 90}, + }, + "a channel, Updates off, then on": { + Config{Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, Categories: noUpdates, DiskThreshold: 90}, + Config{Channels: []Channel{{ID: "a1", Name: "Phone", URL: phone}}, Categories: everything, DiskThreshold: 90}, + }, + } { + t.Run(name, func(t *testing.T) { + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + saveConfig(t, h, tc.before) + + h.checkOSSupport() + h.sending.Wait() + if len(o.sent) != 0 || osMarker(h) != "" || len(h.sent) != 0 { + t.Fatalf("sent %+v, marker %q, on record %v: want nothing spent", o.sent, osMarker(h), h.sent) + } + onDay(t, now, "2026-10-11") + h.checkOSSupport() + h.sending.Wait() + if len(o.sent) != 0 || osMarker(h) != "" { + t.Fatalf("a day later, still nothing to tell with: sent %+v, marker %q", o.sent, osMarker(h)) + } + + saveConfig(t, h, tc.after) + onDay(t, now, "2026-10-12") + h.checkOSSupport() + h.sending.Wait() + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Debian 11 no longer receives Debian security updates since 31 August 2026.") { + t.Fatalf("sent %q, want the state told now that it can be", got) + } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker = %q", got) + } + // and then it is told + onDay(t, now, "2026-10-13") + h.checkOSSupport() + h.sending.Wait() + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("sent %q, want it told once", got) + } + }) + } +} + +// A message that fails to leave does not spend the state either: with every channel failing the +// marker is not written, and the next look, with the network back, tells it, even when it comes +// inside the six quiet hours of an alert already sent. One channel taking it is enough. +func TestAStateIsNotSpentOnAFailedSend(t *testing.T) { + h, o, now := newHub(t) // the hub's clock stays at 06:00 on 2026-09-25 + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + clock := *now + h.Now = func() time.Time { return clock } + o.fail[phone] = errors.New("network is unreachable") + o.fail[mail] = errors.New("network is unreachable") + + h.checkOSSupport() + h.sending.Wait() + if len(o.sent) != 0 || osMarker(h) != "" || len(h.sent) != 0 { + t.Fatalf("sent %+v, marker %q, on record %v: want nothing spent", o.sent, osMarker(h), h.sent) + } + if h.lastFailure == nil { + t.Fatal("the failure was not kept for the dashboard") + } + + // one look later, a minute on, the phone's server is back and the mail's is not + clock = clock.Add(time.Minute) + delete(o.fail, phone) + h.checkOSSupport() + h.sending.Wait() + if len(o.texts(phone)) != 1 || len(o.texts(mail)) != 0 { + t.Fatalf("sent %+v, want the one channel that works to tell it", o.sent) + } + if got := osMarker(h); got != "debian-11-security_ended\n" { + t.Fatalf("marker = %q: one channel took the message", got) + } + clock = clock.Add(24 * time.Hour) + delete(o.fail, mail) + h.checkOSSupport() + h.sending.Wait() + if len(o.sent) != 1 { + t.Fatalf("sent %+v, want it told once", o.sent) + } +} + +// With a state directory that cannot be written the state is still told, once while the core runs. +func TestAStateIsToldOnceEvenIfTheMarkerCannotBeWritten(t *testing.T) { + h, o, now := newHub(t) + // the marker is a directory: it can be neither read nor replaced + if err := os.MkdirAll(filepath.Join(h.Root, OSSupportNotifiedFile), 0o755); err != nil { + t.Fatal(err) + } + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + // the hub's clock jumps a day at each look, so that only what the watcher remembers of the state + // stands between it and a second message: not the six quiet hours of an alert already sent + var looks atomic.Int64 + base := *now + h.Now = func() time.Time { return base.Add(time.Duration(looks.Add(1)) * 24 * time.Hour) } + + h.checkOSSupport() + h.checkOSSupport() + h.sending.Wait() + + if len(o.texts(phone)) != 1 { + t.Fatalf("sent %q, want the state told once", o.texts(phone)) + } +} + +// An alert already sent stays quiet for six hours, but under its own key: the next state of the same +// release, a moment later, is not a repeat of the last. +func TestTheSixQuietHoursDoNotHideTheNextState(t *testing.T) { + h, o, _ := newHub(t) // the hub's clock stays at 06:00 on 2026-09-25 + day := time.Date(2027, 5, 31, 0, 0, 0, 0, time.UTC) + h.OSSupport = func() ossupport.Report { return ossupport.Describe(ubuntu22, "amd64", day, nil) } + + h.checkOSSupport() + day = day.AddDate(0, 0, 2) + h.checkOSSupport() + h.sending.Wait() + + got := o.texts(phone) + if len(got) != 2 || !strings.Contains(got[0], "stops receiving") || !strings.Contains(got[1], "no longer receives") { + t.Fatalf("sent %q, want the two states told, a moment apart", got) + } +} + +// The daily look: the first one waits, the next ones come every day, a state that changes between +// two of them is told, and the goroutine ends with its context. +func TestTheDailyLookTellsWhatChangedAndStopsWithItsContext(t *testing.T) { + first, every := osSupportFirst, osSupportEvery + osSupportFirst, osSupportEvery = time.Millisecond, time.Millisecond + t.Cleanup(func() { osSupportFirst, osSupportEvery = first, every }) + + h, o, _ := newHub(t) + var looks atomic.Int32 + h.OSSupport = func() ossupport.Report { + n := looks.Add(1) + switch { + case n < 3: + return ossupport.Describe(ubuntu22, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), nil) + case n < 6: + return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 1, 15, 0, 0, 0, 0, time.UTC), nil) + } + return ossupport.Describe(ubuntu22, "amd64", time.Date(2027, 6, 10, 0, 0, 0, 0, time.UTC), nil) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + h.watchOSSupport(ctx) + close(done) + }() + deadline := time.After(10 * time.Second) + for looks.Load() < 9 { + select { + case <-deadline: + t.Fatalf("only %d looks", looks.Load()) + case <-time.After(time.Millisecond): + } + } + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("the watcher did not stop with its context") + } + h.sending.Wait() + + got := o.texts(phone) + if len(got) != 2 || !strings.Contains(got[0], "stops receiving") || !strings.Contains(got[1], "no longer receives") { + t.Fatalf("sent %q, want the two states told once each across the looks", got) + } +} + +func TestTheFirstLookWaitsAndCanBeCancelled(t *testing.T) { + first := osSupportFirst + osSupportFirst = time.Hour + t.Cleanup(func() { osSupportFirst = first }) + + h, _, _ := newHub(t) + var looks atomic.Int32 + h.OSSupport = func() ossupport.Report { looks.Add(1); return ossupport.Report{} } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + h.watchOSSupport(ctx) + close(done) + }() + time.Sleep(20 * time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("the watcher did not stop with its context") + } + if looks.Load() != 0 { + t.Fatalf("looked %d times before the first delay was over", looks.Load()) + } +} + +// The hub's own Run is what starts the daily look: a box whose Debian is out of support is told +// shortly after the core starts. +func TestRunStartsTheDailyLook(t *testing.T) { + first := osSupportFirst + osSupportFirst = time.Millisecond + t.Cleanup(func() { osSupportFirst = first }) + + h, o, now := newHub(t) + osFrom(h, debian11, now) + onDay(t, now, "2026-10-10") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + h.Run(ctx) + close(done) + }() + deadline := time.After(10 * time.Second) + for { + o.mu.Lock() + told := len(o.sent) + o.mu.Unlock() + // the marker is the last thing the look does, after the send: the state directory is not + // removed under it + if told > 0 && osMarker(h) != "" { + break + } + select { + case <-deadline: + cancel() + t.Fatal("nothing was told") + case <-time.After(time.Millisecond): + } + } + cancel() + <-done + h.sending.Wait() + + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Debian 11 no longer receives Debian security updates since 31 August 2026.") { + t.Fatalf("sent %q", got) + } +} diff --git a/pkg/ossupport/ossupport.go b/pkg/ossupport/ossupport.go new file mode 100644 index 00000000..2c62c739 --- /dev/null +++ b/pkg/ossupport/ossupport.go @@ -0,0 +1,363 @@ +// Package ossupport says whether the operating system the core runs on still receives security +// updates, from a table of dates kept in this file. It only reads and reports: nothing here +// upgrades anything, and nothing here asks the network, apt or the box's own tables (the answer +// is the same on a box with a stale distro-info-data). +// +// Four rules keep it from saying something false: +// - the identity is strict: ID must be exactly "debian" or "ubuntu". ID_LIKE is never looked at, +// so Mint, Pop!_OS, Raspbian (32-bit, ID=raspbian) and the rest are not known, and nothing is +// claimed; +// - but ID cannot tell a plain Debian or Ubuntu from the systems that keep its own os-release +// (ID=debian, no ID_LIKE): Raspberry Pi OS (64-bit, and Bookworm and later), Armbian, DietPi, +// Proxmox VE and OpenMediaVault. Those are found by marker files, read through the exists seam +// of Describe, and are not known either: the answer says which Variant it is, claims nothing, +// and offers no next release, because each of them upgrades by its own guide, or not in place; +// - a release that is not in the table (Debian 14, Ubuntu 26.10, any non-LTS Ubuntu, 18.04) is +// "unknown", not guessed; +// - eighteen months after the day the table was last checked (AsOf) what is still to come is +// "unknown": a table nobody has updated is no longer a source for it. What has ended stays +// ended, since an end that has passed cannot un-end. +package ossupport + +import ( + "strings" + "time" + "unicode" +) + +// The states of Support.State. +const ( + // Supported: the vendor's free security updates go on for more than 180 days. + Supported = "supported" + // EndingSoon: they end within 180 days, or today. + EndingSoon = "ending_soon" + // SecurityEnded: they ended yesterday or before. + SecurityEnded = "security_ended" + // Unknown: no claim is made. + Unknown = "unknown" +) + +// The precisions of Support.Ends: the vendor says the day, or only the month. A date the vendor's +// page gives as a month ("May 2027") is PrecisionMonth, whatever day another source puts in it, and +// is shown as the month. +const ( + PrecisionDay = "day" + PrecisionMonth = "month" +) + +// StateReport is the State of a Next: it is a report, read-only. Nothing in this package, and +// nothing that reads it yet, upgrades the operating system. +const StateReport = "report" + +const ( + dateLayout = "2006-01-02" + // staleAfterMonths is how long after AsOf the table is still believed. + staleAfterMonths = 18 + // endingSoonDays is how long before the end the state is EndingSoon. + endingSoonDays = 180 +) + +// AsOf is the day the table below was last checked against its sources. A var so that a test can +// move it; nothing else does. +var AsOf = time.Date(2026, 10, 9, 0, 0, 0, 0, time.UTC) + +// Identity is the operating system as os-release says it, validated: a field that is not what it +// should look like is left empty, and absent from the JSON. +type Identity struct { + ID string `json:"id,omitempty"` + VersionID string `json:"version_id,omitempty"` + Codename string `json:"codename,omitempty"` + PrettyName string `json:"pretty_name,omitempty"` + Arch string `json:"arch,omitempty"` + // Known is true for ID "debian" or "ubuntu", exactly, when it is not a Variant, and for nothing + // else. + Known bool `json:"known"` + // Variant is what a box that says ID "debian" or "ubuntu" really is, when it is not the plain + // distribution: one of the Variant constants. Such a box is not Known. + Variant string `json:"variant,omitempty"` +} + +// The variants, the values of Identity.Variant. +const ( + VariantRaspberryPi = "raspberrypi" + VariantArmbian = "armbian" + VariantDietPi = "dietpi" + VariantProxmox = "proxmox" + VariantOpenMediaVault = "openmediavault" +) + +// variantMarkers are the files that say a box is a variant, the first one found deciding: Raspberry +// Pi OS writes /etc/rpi-issue (its os-release is Debian's own, 64-bit and from Bookworm); Armbian +// /etc/armbian-release; DietPi /boot/dietpi.txt and /etc/dietpi; Proxmox VE has /usr/bin/pveversion +// and mounts /etc/pve; OpenMediaVault has /etc/openmediavault and /usr/sbin/omv-confdbadm. +// Sources: Raspberry Pi forum t=329094, Proxmox forum "we re-use the /etc/os-release file from +// Debian", the DietPi forum, docs.armbian.com/build-framework/switches/branding, and the packages' +// own file lists. +var variantMarkers = []struct { + variant string + paths []string +}{ + {VariantRaspberryPi, []string{"/etc/rpi-issue"}}, + {VariantArmbian, []string{"/etc/armbian-release"}}, + {VariantDietPi, []string{"/boot/dietpi.txt", "/etc/dietpi"}}, + {VariantProxmox, []string{"/usr/bin/pveversion", "/etc/pve"}}, + {VariantOpenMediaVault, []string{"/etc/openmediavault", "/usr/sbin/omv-confdbadm"}}, +} + +// Extended is the vendor's later, separate programme of security maintenance. Only its name and +// its last day are said: what it costs, and for whom, is the vendor's to say. Precision is that of +// Ends, as for a Support. +type Extended struct { + Name string `json:"name"` + Ends string `json:"ends"` + Precision string `json:"precision"` +} + +// Next is the release after this one. State is StateReport: it is information only. +type Next struct { + ID string `json:"id"` + VersionID string `json:"version_id"` + Codename string `json:"codename"` + PrettyName string `json:"pretty_name"` + State string `json:"state"` +} + +// Support is how long the vendor's free security updates last for an Identity. +type Support struct { + State string `json:"state"` + // Ends is the last day of the free security updates, "2006-01-02", with Precision "day"; with + // "month" the vendor says only the month, and only the month is to be shown. + Ends string `json:"ends,omitempty"` + Precision string `json:"precision,omitempty"` + Extended *Extended `json:"extended,omitempty"` + // Source is the page that says it. + Source string `json:"source,omitempty"` + // Next is not part of the JSON of a Support: a Report carries it beside it. + Next *Next `json:"-"` +} + +// Report is what GET /v1/sys/os answers: who the box is, how long it is supported, and the next +// release when the table has one. +type Report struct { + Identity + Support Support `json:"support"` + Next *Next `json:"next,omitempty"` +} + +// entry is one row of the table. Dates are the day the free security updates end, the vendor's +// extended programme being a later, separate one. sources are the pages the dates come from; +// the first is the one the API gives. +type entry struct { + id, versionID, codename string + ends, precision string + extended *Extended + sources []string + next *Next +} + +// table is the whole of what is claimed, checked against its sources on AsOf. A box that is not +// in it is "unknown". +// +// The Debian rows say the day, as the Debian pages do. The Ubuntu rows say only the month, because +// the page they cite (ubuntu.com/about/release-cycle) gives only a month for every end, the +// standard one and the extended one ("May 2027", "May 2032"), and the other pages disagree on the +// day and sometimes on the month. Where Canonical's pages differ (checked on 2026-10-11) the earlier +// month is the one kept, which is the side a warning is safe on: the release team's list says +// "Jun 2027" and "Jun 2029" for 22.04 and 24.04 where the release-cycle page says May, and +// distro-info-data and that list say April for the ESM of 20.04 where the release-cycle page and +// the 20.04 post say May 2030 (distro-info-data: April for all four). The day of an Ubuntu row is +// the one that carries that month: distro-info-data's, except for 22.04, whose 2027-06-01 is the day +// after its last (the release-cycle page gives 2027-05-31). +var table = []entry{ + { + id: "debian", versionID: "11", codename: "bullseye", + ends: "2026-08-31", precision: PrecisionDay, + extended: &Extended{Name: "Debian ELTS (Freexian)", Ends: "2031-06-30", Precision: PrecisionDay}, + sources: []string{"https://www.debian.org/News/2026/20260831", "https://wiki.debian.org/LTS/Extended"}, + next: &Next{ID: "debian", VersionID: "12", Codename: "bookworm", PrettyName: "Debian 12 (bookworm)", State: StateReport}, + }, + { + // free security by Debian LTS, after the security team's own end (2026-07-11 on the releases + // page, 12 July in the news): LTS covers i386, amd64, armhf, arm64 and ppc64el only + id: "debian", versionID: "12", codename: "bookworm", + ends: "2028-06-30", precision: PrecisionDay, + sources: []string{"https://www.debian.org/News/2026/20260712", "https://wiki.debian.org/LTS"}, + next: &Next{ID: "debian", VersionID: "13", Codename: "trixie", PrettyName: "Debian 13 (trixie)", State: StateReport}, + }, + { + // free security by Debian LTS, after the security team's own end on 2028-08-09 + id: "debian", versionID: "13", codename: "trixie", + ends: "2030-06-30", precision: PrecisionDay, + sources: []string{"https://www.debian.org/releases/"}, + }, + { + // No upgrade path is offered: the installer never supported 20.04. Its standard support ended + // "May 2025": the release team's post says 29 May, Canonical's blog 31 May. + id: "ubuntu", versionID: "20.04", codename: "focal", + ends: "2025-05-29", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2030-04-23", Precision: PrecisionMonth}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://ubuntu.com/blog/20.04-end-of-standard-support", + "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + }, + { + id: "ubuntu", versionID: "22.04", codename: "jammy", + ends: "2027-05-31", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2032-04-21", Precision: PrecisionMonth}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://ubuntu.com/project/docs/release-team/list-of-releases/", + "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + next: &Next{ID: "ubuntu", VersionID: "24.04", Codename: "noble", PrettyName: "Ubuntu 24.04 LTS (noble)", State: StateReport}, + }, + { + id: "ubuntu", versionID: "24.04", codename: "noble", + ends: "2029-05-31", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2034-04-25", Precision: PrecisionMonth}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://ubuntu.com/project/docs/release-team/list-of-releases/", + "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + next: &Next{ID: "ubuntu", VersionID: "26.04", Codename: "resolute", PrettyName: "Ubuntu 26.04 LTS (resolute)", State: StateReport}, + }, + { + id: "ubuntu", versionID: "26.04", codename: "resolute", + ends: "2031-05-29", precision: PrecisionMonth, + extended: &Extended{Name: "Ubuntu Pro (ESM)", Ends: "2036-04-23", Precision: PrecisionMonth}, + sources: []string{"https://ubuntu.com/about/release-cycle", "https://git.launchpad.net/ubuntu/+source/distro-info-data/plain/ubuntu.csv?h=ubuntu/devel"}, + }, +} + +// Parse is the identity of the box from its os-release and its architecture. It is strict: only an +// ID of exactly "debian" or "ubuntu" is Known, and ID_LIKE is never looked at. Every field is +// checked for length and characters before it goes anywhere (a log, a page, an alert): one that +// fails is left empty. The only variant Parse can tell is Armbian, whose os-release names it (its +// PRETTY_NAME is " ", "Armbian-unofficial" when the vendor is not set); +// the others are told by their files, in Describe. +func Parse(osRelease map[string]string, arch string) Identity { + id := osRelease["ID"] + identity := Identity{ + ID: token(id, 32), + VersionID: token(osRelease["VERSION_ID"], 32), + Codename: token(osRelease["VERSION_CODENAME"], 32), + PrettyName: prettyName(osRelease["PRETTY_NAME"]), + Arch: token(arch, 16), + Known: id == "debian" || id == "ubuntu", + } + if identity.Known && strings.HasPrefix(osRelease["PRETTY_NAME"], "Armbian") { + identity.Known, identity.Variant = false, VariantArmbian + } + return identity +} + +// variantOf is the variant whose marker file exists, or "". exists says whether a path exists on the +// box; nil means no file does. +func variantOf(exists func(path string) bool) string { + if exists == nil { + return "" + } + for _, m := range variantMarkers { + for _, path := range m.paths { + if exists(path) { + return m.variant + } + } + } + return "" +} + +// token is s when it is 1 to limit characters of a-z, 0-9, ".", "_" and "-", and "" otherwise. +func token(s string, limit int) string { + if s == "" || len(s) > limit { + return "" + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-') { + return "" + } + } + return s +} + +// prettyName is s when it is 1 to 64 letters, digits, spaces and . , : ( ) / + _ - !, and "" +// otherwise: nothing a page could take for markup, and no control or direction character. +func prettyName(s string) string { + n := 0 + for _, c := range s { + n++ + if !(unicode.IsLetter(c) || unicode.IsDigit(c) || c == ' ' || c == '.' || c == ',' || c == ':' || + c == '(' || c == ')' || c == '/' || c == '+' || c == '_' || c == '-' || c == '!') { + return "" + } + } + if n == 0 || n > 64 { + return "" + } + return s +} + +// dayOf is the calendar day of t in UTC, at midnight. +func dayOf(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// Evaluate is how long the free security updates of id last, on the day of now (UTC). The last day +// is a day of support: SecurityEnded begins the day after it, and EndingSoon the 180th day before +// it. A box that is not Known, a release the table does not have, a codename that is not the +// release's (a derivative, a testing system) and any day before AsOf are Unknown. More than 18 +// months after AsOf the table is stale: Supported and EndingSoon, which a vendor can still move, +// are Unknown then, and SecurityEnded stays SecurityEnded, with only the day it ended and its +// source: what the vendor offers after it, and the next release, are claims about a time the table +// has not been looked at for. +func Evaluate(id Identity, now time.Time) Support { + unknown := Support{State: Unknown} + today := dayOf(now) + // A day before AsOf is a clock that is wrong: the table was written on AsOf, so no box that + // runs this code is earlier than that. + if !id.Known || today.Before(AsOf) { + return unknown + } + stale := today.After(AsOf.AddDate(0, staleAfterMonths, 0)) + for _, e := range table { + if e.id != id.ID || e.versionID != id.VersionID || (id.Codename != "" && id.Codename != e.codename) { + continue + } + end, err := time.Parse(dateLayout, e.ends) + if err != nil { + return unknown + } + support := Support{State: Supported, Ends: e.ends, Precision: e.precision, Source: e.sources[0]} + switch { + case today.After(end): + support.State = SecurityEnded + case end.Sub(today) <= endingSoonDays*24*time.Hour: + support.State = EndingSoon + } + if stale { + if support.State != SecurityEnded { + return unknown + } + return support + } + if e.extended != nil { + extended := *e.extended + support.Extended = &extended + } + if e.next != nil { + next := *e.next + support.Next = &next + } + return support + } + return unknown +} + +// Describe is Parse, the look for a variant and Evaluate together, as the API answers them. exists +// says whether a path exists on the box (nil: none does); it is asked only when the box says +// "debian" or "ubuntu", and the variant it finds is not Known and has no support to claim. +func Describe(osRelease map[string]string, arch string, now time.Time, exists func(path string) bool) Report { + id := Parse(osRelease, arch) + if id.Known { + if variant := variantOf(exists); variant != "" { + id.Known, id.Variant = false, variant + } + } + support := Evaluate(id, now) + return Report{Identity: id, Support: support, Next: support.Next} +} diff --git a/pkg/ossupport/ossupport_test.go b/pkg/ossupport/ossupport_test.go new file mode 100644 index 00000000..3ad2330c --- /dev/null +++ b/pkg/ossupport/ossupport_test.go @@ -0,0 +1,639 @@ +package ossupport + +import ( + "encoding/json" + "slices" + "strings" + "testing" + "time" +) + +// day is midnight UTC of a date written "2006-01-02". +func day(t *testing.T, s string) time.Time { + t.Helper() + d, err := time.Parse(dateLayout, s) + if err != nil { + t.Fatal(err) + } + return d +} + +// release is an os-release as the box would give it. +func release(id, versionID, codename, pretty string) map[string]string { + r := map[string]string{"ID": id, "PRETTY_NAME": pretty} + if versionID != "" { + r["VERSION_ID"] = versionID + } + if codename != "" { + r["VERSION_CODENAME"] = codename + } + return r +} + +// asOf moves the day the table was checked for the length of the test. +func asOf(t *testing.T, s string) { + t.Helper() + previous := AsOf + AsOf = day(t, s) + t.Cleanup(func() { AsOf = previous }) +} + +func TestOnlyDebianAndUbuntuAreKnown(t *testing.T) { + for name, r := range map[string]map[string]string{ + // Every look-alike has an ID_LIKE of debian or ubuntu, and the version of the release it is + // built on: none of them is known, and none is told a status. (Raspberry Pi OS 64-bit, + // Armbian, DietPi, Proxmox VE and OpenMediaVault are not here: they keep Debian's own + // os-release, ID=debian and no ID_LIKE, and are told by their files, below.) + "Linux Mint": {"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia"}, + "Mint on jammy": {"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, + "Pop!_OS": {"ID": "pop", "ID_LIKE": "ubuntu debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, + "Zorin": {"ID": "zorin", "ID_LIKE": "ubuntu debian", "VERSION_ID": "17", "VERSION_CODENAME": "jammy"}, + "elementary": {"ID": "elementary", "ID_LIKE": "ubuntu", "VERSION_ID": "7.1"}, + "Raspbian 32-bit": {"ID": "raspbian", "ID_LIKE": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye"}, + // the name of a variant is not an ID: only the files, and Armbian's own PRETTY_NAME, say it + "an Armbian ID": {"ID": "armbian", "VERSION_ID": "24.2.1", "PRETTY_NAME": "Armbian 24.2.1 bookworm"}, + "Kali": {"ID": "kali", "ID_LIKE": "debian", "VERSION_ID": "2024.1"}, + "Devuan": {"ID": "devuan", "ID_LIKE": "debian", "VERSION_ID": "5", "VERSION_CODENAME": "daedalus"}, + "Ubuntu Core": {"ID": "ubuntu-core", "ID_LIKE": "ubuntu", "VERSION_ID": "22"}, + "only an ID_LIKE": {"ID_LIKE": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "no ID at all": {"VERSION_ID": "12"}, + "Debian, loudly": {"ID": "Debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "DEBIAN": {"ID": "DEBIAN", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "debian and more": {"ID": "debian2", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "padded": {"ID": " debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "two IDs": {"ID": "debian ubuntu", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "a newline": {"ID": "debian\n", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, + "Ubuntu, loudly": {"ID": "Ubuntu", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy"}, + "nothing": {}, + } { + t.Run(name, func(t *testing.T) { + id := Parse(r, "amd64") + if id.Known || id.Variant != "" { + t.Fatalf("Parse(%v) is known, or a variant of what it is not: %+v", r, id) + } + if got := Describe(r, "amd64", day(t, "2026-10-10"), nil); got.Support.State != Unknown || got.Next != nil || got.Support.Ends != "" || got.Support.Extended != nil { + t.Fatalf("Describe(%v) claims %+v, want nothing", r, got) + } + }) + } + for _, c := range []struct{ id, want string }{{"debian", "debian"}, {"ubuntu", "ubuntu"}} { + if id := Parse(map[string]string{"ID": c.id, "ID_LIKE": "something else"}, "amd64"); !id.Known || id.ID != c.want { + t.Errorf("Parse(ID=%s) = %+v, want it known", c.id, id) + } + } + // ID_LIKE is not looked at, whatever it says and whatever ID is + if id := Parse(map[string]string{"ID": "debian", "ID_LIKE": "fedora"}, "amd64"); !id.Known { + t.Errorf("a Debian whose ID_LIKE is fedora is not known: %+v", id) + } +} + +// has is a box on which exactly these paths exist. +func has(paths ...string) func(string) bool { + return func(path string) bool { return slices.Contains(paths, path) } +} + +// The systems that keep Debian's or Ubuntu's own os-release (ID=debian, no ID_LIKE, the +// distribution's own PRETTY_NAME) are not known, whatever it says: their files say what they are, the +// answer says which variant, and it claims nothing and offers no next release, since each of them +// upgrades by its own guide, or not in place. +func TestAVariantIsNotKnownWhateverItsOSReleaseSays(t *testing.T) { + now := day(t, "2026-10-10") + debian := func(version, codename string) map[string]string { + return map[string]string{"ID": "debian", "VERSION_ID": version, "VERSION_CODENAME": codename, "PRETTY_NAME": "Debian GNU/Linux " + version + " (" + codename + ")"} + } + for name, c := range map[string]struct { + release map[string]string + arch string + files []string + variant string + }{ + "Raspberry Pi OS 64-bit, Bookworm": {debian("12", "bookworm"), "arm64", []string{"/etc/rpi-issue"}, VariantRaspberryPi}, + "Raspberry Pi OS 64-bit, Bullseye": {debian("11", "bullseye"), "arm64", []string{"/etc/rpi-issue"}, VariantRaspberryPi}, + "Armbian, by its file": {debian("12", "bookworm"), "arm64", []string{"/etc/armbian-release"}, VariantArmbian}, + "DietPi, by /boot/dietpi.txt": {debian("12", "bookworm"), "arm64", []string{"/boot/dietpi.txt"}, VariantDietPi}, + "DietPi, by /etc/dietpi": {debian("12", "bookworm"), "amd64", []string{"/etc/dietpi"}, VariantDietPi}, + "Proxmox VE 7, by pveversion": {debian("11", "bullseye"), "amd64", []string{"/usr/bin/pveversion"}, VariantProxmox}, + "Proxmox VE 8, by its /etc/pve": {debian("12", "bookworm"), "amd64", []string{"/etc/pve"}, VariantProxmox}, + "OpenMediaVault, by its /etc dir": {debian("11", "bullseye"), "amd64", []string{"/etc/openmediavault"}, VariantOpenMediaVault}, + "OpenMediaVault, by omv-confdbadm": {debian("12", "bookworm"), "amd64", []string{"/usr/sbin/omv-confdbadm"}, VariantOpenMediaVault}, + "Ubuntu on a Raspberry Pi": {release("ubuntu", "22.04", "jammy", "Ubuntu 22.04.5 LTS"), "arm64", []string{"/etc/rpi-issue"}, VariantRaspberryPi}, + "Proxmox on top of a Raspberry Pi": {debian("12", "bookworm"), "arm64", []string{"/etc/pve", "/etc/rpi-issue"}, VariantRaspberryPi}, + "Armbian, by its PRETTY_NAME alone": {map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Armbian 24.11.0-trunk.81 bookworm"}, "arm64", nil, VariantArmbian}, + "Armbian-unofficial, no file": {map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Armbian-unofficial 24.2.1 bookworm"}, "arm64", nil, VariantArmbian}, + } { + t.Run(name, func(t *testing.T) { + got := Describe(c.release, c.arch, now, has(c.files...)) + if got.Known || got.Variant != c.variant { + t.Fatalf("known = %v, variant = %q, want not known and %q: %+v", got.Known, got.Variant, c.variant, got) + } + if got.Support.State != Unknown || got.Support.Ends != "" || got.Support.Precision != "" || got.Support.Extended != nil || got.Support.Source != "" || got.Next != nil { + t.Fatalf("claims %+v and next %+v, want nothing", got.Support, got.Next) + } + // who it says it is, it still says: the dashboard may show it + if got.ID != c.release["ID"] || got.VersionID != c.release["VERSION_ID"] || got.Codename != c.release["VERSION_CODENAME"] || got.Arch != c.arch { + t.Fatalf("identity = %+v, want what the os-release says", got.Identity) + } + }) + } +} + +// The exact answer of a Raspberry Pi OS 64-bit image, and of an Armbian that its PRETTY_NAME gives +// away: the variant is said, the support is not. +func TestTheReportOfAVariantAsJSON(t *testing.T) { + for _, c := range []struct { + name string + release map[string]string + files []string + want string + }{ + { + "Raspberry Pi OS 64-bit, Bookworm", + map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Debian GNU/Linux 12 (bookworm)"}, + []string{"/etc/rpi-issue"}, + `{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian GNU/Linux 12 (bookworm)","arch":"arm64","known":false,"variant":"raspberrypi","support":{"state":"unknown"}}`, + }, + { + "Armbian", + map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Armbian 24.11.0-trunk.81 bookworm"}, + nil, + `{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Armbian 24.11.0-trunk.81 bookworm","arch":"arm64","known":false,"variant":"armbian","support":{"state":"unknown"}}`, + }, + } { + got, err := json.Marshal(Describe(c.release, "arm64", day(t, "2026-10-10"), has(c.files...))) + if err != nil { + t.Fatal(err) + } + if string(got) != c.want { + t.Errorf("%s:\n got %s\nwant %s", c.name, got, c.want) + } + } +} + +// A plain Debian or Ubuntu, with none of the files, is known and has its status; the box is asked +// about exactly the files the table names, and about none for a system that does not say "debian" or +// "ubuntu". +func TestAPlainDebianOrUbuntuHasNoneOfTheMarkers(t *testing.T) { + var asked []string + exists := func(path string) bool { asked = append(asked, path); return false } + now := day(t, "2026-10-10") + for _, c := range []struct { + release map[string]string + want string + }{ + {release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)"), SecurityEnded}, + {release("debian", "12", "bookworm", "Debian GNU/Linux 12 (bookworm)"), Supported}, + {release("ubuntu", "22.04", "jammy", "Ubuntu 22.04.5 LTS"), Supported}, + } { + asked = nil + got := Describe(c.release, "amd64", now, exists) + if !got.Known || got.Variant != "" || got.Support.State != c.want || got.Next == nil && c.release["VERSION_ID"] != "13" { + t.Errorf("%s %s with none of the files: %+v", c.release["ID"], c.release["VERSION_ID"], got) + } + slices.Sort(asked) + want := []string{"/boot/dietpi.txt", "/etc/armbian-release", "/etc/dietpi", "/etc/openmediavault", "/etc/pve", "/etc/rpi-issue", "/usr/bin/pveversion", "/usr/sbin/omv-confdbadm"} + if !slices.Equal(asked, want) { + t.Errorf("the box was asked about %v, want %v", asked, want) + } + } + asked = nil + if got := Describe(release("linuxmint", "21.3", "virginia", ""), "amd64", now, exists); got.Known || got.Variant != "" || len(asked) != 0 { + t.Errorf("a Mint: %+v, files asked %v: want not known, no variant and no file looked for", got, asked) + } + // a path that is not a marker says nothing, and a box with no seam has no files + if got := Describe(release("debian", "12", "bookworm", ""), "amd64", now, has("/etc/os-release", "/etc/debian_version")); !got.Known || got.Variant != "" { + t.Errorf("a Debian with ordinary files: %+v", got) + } + if got := Describe(release("debian", "12", "bookworm", ""), "amd64", now, nil); !got.Known || got.Variant != "" { + t.Errorf("a Debian with no way to look for files: %+v", got) + } +} + +// A variant is not told a status, however far the table is from its day: nothing leaks through the +// stale rule either. +func TestAVariantStaysUnknownWhateverTheDay(t *testing.T) { + r := release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)") + for _, now := range []string{"2026-10-10", "2027-10-10", "2028-05-01", "2035-01-01"} { + got := Describe(r, "amd64", day(t, now), has("/usr/bin/pveversion")) + if got.Known || got.Support.State != Unknown || got.Next != nil { + t.Errorf("%s: %+v", now, got) + } + } +} + +func TestParseKeepsWhatIsValidAndDropsWhatIsNot(t *testing.T) { + good := Parse(release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)"), "amd64") + if want := (Identity{ID: "debian", VersionID: "11", Codename: "bullseye", PrettyName: "Debian GNU/Linux 11 (bullseye)", Arch: "amd64", Known: true}); good != want { + t.Fatalf("Parse = %+v, want %+v", good, want) + } + if got := Parse(release("ubuntu", "22.04", "jammy", "Ubuntu 22.04.5 LTS"), "arm64"); got.PrettyName != "Ubuntu 22.04.5 LTS" || got.Arch != "arm64" { + t.Errorf("Parse = %+v", got) + } + + long := strings.Repeat("a", 33) + for name, c := range map[string]struct { + field string + value string + }{ + "a version id with a space": {"VERSION_ID", "11 "}, + "a version id with markup": {"VERSION_ID", "11"}, + "a version id with a newline": {"VERSION_ID", "11\n12"}, + "a version id that is too long": {"VERSION_ID", long}, + "a version id in capitals": {"VERSION_ID", "RC1"}, + "a codename with a quote": {"VERSION_CODENAME", `bull"seye`}, + "a codename with a slash": {"VERSION_CODENAME", "../etc"}, + "a codename that is too long": {"VERSION_CODENAME", long}, + "a pretty name with markup": {"PRETTY_NAME", "Debian "}, + "a pretty name with a quote": {"PRETTY_NAME", `Debian "11"`}, + "a pretty name with an ampersand": {"PRETTY_NAME", "Debian & co"}, + "a pretty name with a newline": {"PRETTY_NAME", "Debian\n11"}, + "a pretty name with an escape": {"PRETTY_NAME", "Debian \x1b[31m11"}, + "a pretty name that reads backwards": {"PRETTY_NAME", "Debian \u202e11"}, + "a pretty name that is too long": {"PRETTY_NAME", strings.Repeat("D", 65)}, + } { + t.Run(name, func(t *testing.T) { + r := release("debian", "11", "bullseye", "Debian GNU/Linux 11 (bullseye)") + r[c.field] = c.value + id := Parse(r, "amd64") + var got string + switch c.field { + case "VERSION_ID": + got = id.VersionID + case "VERSION_CODENAME": + got = id.Codename + case "PRETTY_NAME": + got = id.PrettyName + } + if got != "" { + t.Fatalf("%s %q was kept as %q", c.field, c.value, got) + } + if !id.Known || id.ID != "debian" || id.Arch != "amd64" { + t.Fatalf("the rest of the identity changed: %+v", id) + } + }) + } + // every character a page or a shell could take for something else is enough, alone, to drop a pretty name + for _, c := range "<>&\"'`\\;=${}[]|*?#@%^~\t\x00 ​" { + if got := Parse(release("debian", "11", "bullseye", "Debian "+string(c)+" 11"), "amd64"); got.PrettyName != "" { + t.Errorf("a pretty name with %q was kept: %q", c, got.PrettyName) + } + } + if got := Parse(release("debian", "11", "bullseye", ""), "amd64 \n"); got.Arch != "" { + t.Errorf("an architecture with a newline was kept: %q", got.Arch) + } + if got := Parse(release("debian", "11", "bullseye", strings.Repeat("D", 64)), ""); got.PrettyName == "" || got.Arch != "" { + t.Errorf("a pretty name of 64 characters was dropped, or an empty architecture kept: %+v", got) + } + if got := Parse(release(strings.Repeat("d", 33), "11", "bullseye", ""), "amd64"); got.ID != "" || got.Known { + t.Errorf("an ID that is too long was kept: %+v", got) + } + if got := Parse(nil, "amd64"); got.Known || got.ID != "" { + t.Errorf("Parse(nil) = %+v", got) + } +} + +// The table is exactly what the spec says, and nothing else: the dates are the claims. +func TestTheTableHoldsExactlyTheseEntries(t *testing.T) { + type row struct { + id, version, codename, ends, precision string + extended *Extended + next string // "id version", "" for none + source string + } + want := []row{ + {"debian", "11", "bullseye", "2026-08-31", "day", &Extended{"Debian ELTS (Freexian)", "2031-06-30", "day"}, "debian 12", "https://www.debian.org/News/2026/20260831"}, + {"debian", "12", "bookworm", "2028-06-30", "day", nil, "debian 13", "https://www.debian.org/News/2026/20260712"}, + {"debian", "13", "trixie", "2030-06-30", "day", nil, "", "https://www.debian.org/releases/"}, + // Ubuntu says the month, for every end (see the table): the month shown is the earlier of + // the months Canonical's pages give + {"ubuntu", "20.04", "focal", "2025-05-29", "month", &Extended{"Ubuntu Pro (ESM)", "2030-04-23", "month"}, "", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "22.04", "jammy", "2027-05-31", "month", &Extended{"Ubuntu Pro (ESM)", "2032-04-21", "month"}, "ubuntu 24.04", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "24.04", "noble", "2029-05-31", "month", &Extended{"Ubuntu Pro (ESM)", "2034-04-25", "month"}, "ubuntu 26.04", "https://ubuntu.com/about/release-cycle"}, + {"ubuntu", "26.04", "resolute", "2031-05-29", "month", &Extended{"Ubuntu Pro (ESM)", "2036-04-23", "month"}, "", "https://ubuntu.com/about/release-cycle"}, + } + if len(table) != len(want) { + t.Fatalf("the table has %d entries, want %d", len(table), len(want)) + } + for i, w := range want { + e := table[i] + if e.id != w.id || e.versionID != w.version || e.codename != w.codename || e.ends != w.ends || e.precision != w.precision { + t.Errorf("entry %d is %s %s %s ends %s (%s), want %s %s %s ends %s (%s)", i, e.id, e.versionID, e.codename, e.ends, e.precision, w.id, w.version, w.codename, w.ends, w.precision) + } + if (e.extended == nil) != (w.extended == nil) || (w.extended != nil && *e.extended != *w.extended) { + t.Errorf("%s %s: extended = %+v, want %+v", w.id, w.version, e.extended, w.extended) + } + next := "" + if e.next != nil { + next = e.next.ID + " " + e.next.VersionID + if e.next.State != StateReport || e.next.PrettyName == "" || e.next.Codename == "" { + t.Errorf("%s %s: next = %+v", w.id, w.version, e.next) + } + } + if next != w.next { + t.Errorf("%s %s: next = %q, want %q", w.id, w.version, next, w.next) + } + if len(e.sources) == 0 || e.sources[0] != w.source { + t.Errorf("%s %s: sources = %v, want the first to be %s", w.id, w.version, e.sources, w.source) + } + for _, s := range e.sources { + if !strings.HasPrefix(s, "https://") { + t.Errorf("%s %s: source %q is not https", w.id, w.version, s) + } + } + // the dates are dates, and what is extended starts after what it extends + end := day(t, e.ends) + if e.extended != nil && !day(t, e.extended.Ends).After(end) { + t.Errorf("%s %s: extended ends %s, not after %s", w.id, w.version, e.extended.Ends, e.ends) + } + // a next is the next release of the table, or nothing + if e.next != nil { + found := false + for _, other := range table { + if other.id == e.next.ID && other.versionID == e.next.VersionID && other.codename == e.next.Codename { + found = true + if !day(t, other.ends).After(end) { + t.Errorf("%s %s: its next ends before it", w.id, w.version) + } + } + } + if !found { + t.Errorf("%s %s: next %+v is not in the table", w.id, w.version, e.next) + } + } + } + if got := AsOf.Format(dateLayout); got != "2026-10-09" { + t.Errorf("AsOf = %s, want 2026-10-09", got) + } +} + +// Around every end date: 181 days before, 180, the day before, the day, the day after, with the +// table made fresh for the test (the staleness has its own). +func TestEveryEntryChangesStateOnTheDaysThatMatter(t *testing.T) { + for _, e := range table { + t.Run(e.id+" "+e.versionID, func(t *testing.T) { + end := day(t, e.ends) + asOf(t, "2020-01-01") // restored after the test; each case below sets its own + for _, c := range []struct { + name string + now time.Time + want string + }{ + {"181 days before", end.AddDate(0, 0, -181), Supported}, + {"180 days before", end.AddDate(0, 0, -180), EndingSoon}, + {"179 days before", end.AddDate(0, 0, -179), EndingSoon}, + {"the day before", end.AddDate(0, 0, -1), EndingSoon}, + {"the day, at its first second", end, EndingSoon}, + {"the day, at its last second", end.Add(24*time.Hour - time.Second), EndingSoon}, + {"the day after, at its first second", end.AddDate(0, 0, 1), SecurityEnded}, + {"a year after", end.AddDate(1, 0, 0), SecurityEnded}, + } { + // a table checked a month before the day it is asked about: the staleness stays out of it + AsOf = c.now.AddDate(0, -1, 0) + id := Parse(release(e.id, e.versionID, e.codename, ""), "amd64") + if got := Evaluate(id, c.now); got.State != c.want { + t.Errorf("%s (%s): state = %s, want %s", c.name, c.now.Format(dateLayout), got.State, c.want) + } + } + }) + } +} + +// The day is the UTC day: a box in the Pacific that is still on the 31st is on the 1st for the table. +func TestTheDayIsTheUTCDay(t *testing.T) { + asOf(t, "2026-08-01") + id := Parse(release("debian", "11", "bullseye", ""), "amd64") + utc := time.Date(2026, 8, 31, 23, 59, 59, 0, time.UTC) + pacific := time.FixedZone("UTC-8", -8*3600) + if got := Evaluate(id, utc.In(pacific)).State; got != EndingSoon { + t.Errorf("the last second of 2026-08-31 UTC, written in UTC-8: %s", got) + } + if got := Evaluate(id, utc.Add(time.Second).In(pacific)).State; got != SecurityEnded { + t.Errorf("the first second of 2026-09-01 UTC, written in UTC-8 (still the 31st there): %s", got) + } +} + +// The answers of the real table, today and around the owner's boxes. +func TestTheAnswersOfTheTableOn20261010(t *testing.T) { + now := day(t, "2026-10-10") + for _, c := range []struct { + id, version, codename string + want string + ends, precision string + }{ + {"debian", "11", "bullseye", SecurityEnded, "2026-08-31", "day"}, + {"debian", "12", "bookworm", Supported, "2028-06-30", "day"}, + {"debian", "13", "trixie", Supported, "2030-06-30", "day"}, + {"ubuntu", "20.04", "focal", SecurityEnded, "2025-05-29", "month"}, + {"ubuntu", "22.04", "jammy", Supported, "2027-05-31", "month"}, + {"ubuntu", "24.04", "noble", Supported, "2029-05-31", "month"}, + {"ubuntu", "26.04", "resolute", Supported, "2031-05-29", "month"}, + // a codename left out is not a reason to doubt the version + {"debian", "11", "", SecurityEnded, "2026-08-31", "day"}, + } { + got := Evaluate(Parse(release(c.id, c.version, c.codename, ""), "amd64"), now) + if got.State != c.want || got.Ends != c.ends || got.Precision != c.precision || got.Source == "" { + t.Errorf("%s %s: %+v, want %s ending %s (%s)", c.id, c.version, got, c.want, c.ends, c.precision) + } + } + // Ubuntu 22.04 turns to "ending soon" 180 days before 2027-05-31, and to "ended" on 2027-06-01 + jammy := Parse(release("ubuntu", "22.04", "jammy", ""), "amd64") + for _, c := range []struct{ day, want string }{ + {"2026-12-01", Supported}, + {"2026-12-02", EndingSoon}, + {"2027-05-31", EndingSoon}, + {"2027-06-01", SecurityEnded}, + } { + if got := Evaluate(jammy, day(t, c.day)).State; got != c.want { + t.Errorf("22.04 on %s: %s, want %s", c.day, got, c.want) + } + } +} + +// Every Ubuntu date is a month and every Debian date a day, the extended ones as the end they extend: +// the page Ubuntu's rows cite gives no day, and Debian's give one. A month-precision date is never +// shown as a day: its Extended says so too. +func TestEveryUbuntuDateIsAMonthAndEveryDebianDateADay(t *testing.T) { + asOf(t, "2020-01-01") + for _, e := range table { + want := PrecisionDay + if e.id == "ubuntu" { + want = PrecisionMonth + } + if e.precision != want { + t.Errorf("%s %s: precision %q, want %q", e.id, e.versionID, e.precision, want) + } + if e.extended != nil && e.extended.Precision != want { + t.Errorf("%s %s: extended precision %q, want %q", e.id, e.versionID, e.extended.Precision, want) + } + // the answer carries them + got := Evaluate(Parse(release(e.id, e.versionID, e.codename, ""), "amd64"), day(t, "2020-01-02")) + if got.Precision != want || (got.Extended != nil && got.Extended.Precision != want) { + t.Errorf("%s %s: answered %+v, want precision %s all through", e.id, e.versionID, got, want) + } + } +} + +// What is not in the table is not claimed. +func TestWhatIsNotInTheTableIsUnknown(t *testing.T) { + now := day(t, "2026-10-10") + for name, r := range map[string]map[string]string{ + "Debian 14": release("debian", "14", "forky", ""), + "Debian 10": release("debian", "10", "buster", ""), + "Debian testing": release("debian", "", "", "Debian GNU/Linux trixie/sid"), + "Ubuntu 26.10": release("ubuntu", "26.10", "stonking", ""), + "Ubuntu 25.10": release("ubuntu", "25.10", "questing", ""), + "Ubuntu 18.04": release("ubuntu", "18.04", "bionic", ""), + "Ubuntu 24.04.1": release("ubuntu", "24.04.1", "noble", ""), + "Ubuntu 22.04 as noble": release("ubuntu", "22.04", "noble", ""), + "Debian 11 as trixie": release("debian", "11", "trixie", ""), + "Debian 12 as 11": release("debian", "012", "bookworm", ""), + } { + got := Describe(r, "amd64", now, nil) + if got.Support.State != Unknown || got.Next != nil || got.Support.Ends != "" || got.Support.Source != "" { + t.Errorf("%s: %+v, want unknown and nothing else", name, got.Support) + } + } + // a Debian that is known is still known, and says so + if got := Describe(release("debian", "14", "forky", ""), "amd64", now, nil); !got.Known { + t.Errorf("Debian 14 is Debian: %+v", got) + } +} + +// 18 months after the day the table was checked it is stale: what a vendor can still move +// (supported, ending soon) is "unknown" rather than possibly false, and what has ended stays ended, +// since an end that has passed cannot un-end. A stale ended answer says the day it ended and its +// source, and nothing about what comes after it. +func TestTheTableGoesStaleAfterEighteenMonths(t *testing.T) { + bullseye := Parse(release("debian", "11", "bullseye", ""), "amd64") + bookworm := Parse(release("debian", "12", "bookworm", ""), "amd64") + for _, c := range []struct { + name string + now time.Time + bullseye string + bookworm string + stale bool // extended and next are no longer said + }{ + {"the day it was checked", day(t, "2026-10-09"), SecurityEnded, Supported, false}, + {"a year after", day(t, "2027-10-09"), SecurityEnded, Supported, false}, + {"18 months after, at its first second", day(t, "2028-04-09"), SecurityEnded, EndingSoon, false}, + {"18 months after, at its last second", day(t, "2028-04-09").Add(24*time.Hour - time.Second), SecurityEnded, EndingSoon, false}, + // stale: what is to come is unknown, what ended is not + {"18 months and a day after", day(t, "2028-04-10"), SecurityEnded, Unknown, true}, + {"bookworm's last day, stale", day(t, "2028-06-30"), SecurityEnded, Unknown, true}, + // the table says it ended: a stale table says so too + {"the day after bookworm's, stale", day(t, "2028-07-01"), SecurityEnded, SecurityEnded, true}, + {"two years after", day(t, "2028-10-09"), SecurityEnded, SecurityEnded, true}, + {"far after", day(t, "2040-01-01"), SecurityEnded, SecurityEnded, true}, + // a clock that is wrong is not believed, whatever the state + {"the day before it was checked", day(t, "2026-10-08").Add(23 * time.Hour), Unknown, Unknown, true}, + {"the epoch", time.Unix(0, 0), Unknown, Unknown, true}, + } { + for _, box := range []struct { + name string + id Identity + want string + ended string + }{{"bullseye", bullseye, c.bullseye, "2026-08-31"}, {"bookworm", bookworm, c.bookworm, "2028-06-30"}} { + got := Evaluate(box.id, c.now) + if got.State != box.want { + t.Errorf("%s: %s is %s, want %s", c.name, box.name, got.State, box.want) + continue + } + switch { + case got.State == Unknown: + if got.Ends != "" || got.Precision != "" || got.Next != nil || got.Extended != nil || got.Source != "" { + t.Errorf("%s: %s is %+v, want unknown and nothing else", c.name, box.name, got) + } + case got.Ends != box.ended || got.Source == "": + t.Errorf("%s: %s says %+v, want the day it ends and its source", c.name, box.name, got) + case c.stale && (got.Extended != nil || got.Next != nil): + t.Errorf("%s: %s says extended %+v and next %+v of a table that is stale", c.name, box.name, got.Extended, got.Next) + case !c.stale && got.Next == nil: + t.Errorf("%s: %s lost its next release before the table is stale", c.name, box.name) + } + } + } + // the date it goes stale on moves with AsOf: it is a rule of the table, not a date of the code + asOf(t, "2026-12-30") + if got := Evaluate(bookworm, day(t, "2028-06-30")); got.State != EndingSoon || got.Next == nil { + t.Errorf("with the table checked on 2026-12-30, bookworm on 2028-06-30 is %+v, want it ending soon and not stale yet", got) + } + if got := Evaluate(bookworm, day(t, "2028-07-01")); got.State != SecurityEnded || got.Next != nil { + t.Errorf("with the table checked on 2026-12-30, bookworm on 2028-07-01 is %+v, want it ended and stale", got) + } + if got := Evaluate(bullseye, day(t, "2028-07-01")); got.State != SecurityEnded || got.Next != nil { + t.Errorf("with the table checked on 2026-12-30, bullseye on 2028-07-01 is %+v", got) + } +} + +func TestAnAnswerIsACopyOfTheTable(t *testing.T) { + id := Parse(release("debian", "11", "bullseye", ""), "amd64") + first := Evaluate(id, day(t, "2026-10-10")) + first.Extended.Name, first.Extended.Ends, first.Extended.Precision = "changed", "1999-01-01", "month" + first.Next.PrettyName = "changed" + second := Evaluate(id, day(t, "2026-10-10")) + if second.Extended.Name != "Debian ELTS (Freexian)" || second.Extended.Ends != "2031-06-30" || second.Extended.Precision != "day" || second.Next.PrettyName != "Debian 12 (bookworm)" { + t.Fatalf("a caller changed the table: %+v %+v", second.Extended, second.Next) + } +} + +// Exact JSON, for three boxes: the owner's Debian 11, an Ubuntu that is ending in month precision, +// and a Mint, which is not known. +func TestTheReportsAsJSON(t *testing.T) { + for _, c := range []struct { + name string + release map[string]string + arch string + now string + want string + }{ + { + "the owner's Debian 11", + map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + "amd64", "2026-10-10", + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30","precision":"day"},"source":"https://www.debian.org/News/2026/20260831"},` + + `"next":{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian 12 (bookworm)","state":"report"}}`, + }, + { + "the same Debian 11 with a table that is stale: it has still ended, and nothing about what comes after", + map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + "amd64", "2028-05-01", + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","source":"https://www.debian.org/News/2026/20260831"}}`, + }, + { + "an Ubuntu 22.04 that ends soon, in month precision", + map[string]string{"ID": "ubuntu", "ID_LIKE": "debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy", "PRETTY_NAME": "Ubuntu 22.04.5 LTS"}, + "amd64", "2027-01-15", + `{"id":"ubuntu","version_id":"22.04","codename":"jammy","pretty_name":"Ubuntu 22.04.5 LTS","arch":"amd64","known":true,` + + `"support":{"state":"ending_soon","ends":"2027-05-31","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21","precision":"month"},"source":"https://ubuntu.com/about/release-cycle"},` + + `"next":{"id":"ubuntu","version_id":"24.04","codename":"noble","pretty_name":"Ubuntu 24.04 LTS (noble)","state":"report"}}`, + }, + { + "a Linux Mint", + map[string]string{"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia", "PRETTY_NAME": "Linux Mint 21.3"}, + "amd64", "2026-10-10", + `{"id":"linuxmint","version_id":"21.3","codename":"virginia","pretty_name":"Linux Mint 21.3","arch":"amd64","known":false,"support":{"state":"unknown"}}`, + }, + { + "a Debian 13, supported, with no next release", + map[string]string{"ID": "debian", "VERSION_ID": "13", "VERSION_CODENAME": "trixie", "PRETTY_NAME": "Debian GNU/Linux 13 (trixie)"}, + "arm64", "2026-10-10", + `{"id":"debian","version_id":"13","codename":"trixie","pretty_name":"Debian GNU/Linux 13 (trixie)","arch":"arm64","known":true,` + + `"support":{"state":"supported","ends":"2030-06-30","precision":"day","source":"https://www.debian.org/releases/"}}`, + }, + { + "nothing readable", + nil, "amd64", "2026-10-10", + `{"arch":"amd64","known":false,"support":{"state":"unknown"}}`, + }, + } { + got, err := json.Marshal(Describe(c.release, c.arch, day(t, c.now), nil)) + if err != nil { + t.Fatal(err) + } + if string(got) != c.want { + t.Errorf("%s:\n got %s\nwant %s", c.name, got, c.want) + } + } +} diff --git a/route/v1.go b/route/v1.go index 896832e5..4dbc8c66 100644 --- a/route/v1.go +++ b/route/v1.go @@ -107,6 +107,8 @@ func InitV1Router() http.Handler { // It sat outside the group, readable by anyone on the network. v1SysGroup.GET("/debug", v1.GetSystemConfigDebug) v1SysGroup.GET("/version", v1.GetSystemCheckVersion) // version/check + // The operating system and how long its security updates last: it only reads. + v1SysGroup.GET("/os", v1.GetSystemOS) v1SysGroup.POST("/update", v1.SystemUpdate) v1SysGroup.GET("/packages", v1.GetSystemPackageUpdates) diff --git a/route/v1/system.go b/route/v1/system.go index 061d56a0..220b061d 100644 --- a/route/v1/system.go +++ b/route/v1/system.go @@ -243,6 +243,20 @@ func GetDockerUpdateStatus(ctx echo.Context) error { }) } +// @Summary the operating system, and how long its security updates last +// @Produce application/json +// @Tags sys +// @Security ApiKeyAuth +// @Success 200 {object} model.Result +// @Router /sys/os [get] +func GetSystemOS(ctx echo.Context) error { + return ctx.JSON(http.StatusOK, model.Result{ + Success: common_err.SUCCESS, + Message: common_err.GetMsg(common_err.SUCCESS), + Data: service.MyService.System().GetSystemOS(), + }) +} + // @Summary get logs // @Produce application/json // @Accept application/json diff --git a/route/v1/system_os_test.go b/route/v1/system_os_test.go new file mode 100644 index 00000000..7469ee90 --- /dev/null +++ b/route/v1/system_os_test.go @@ -0,0 +1,118 @@ +package v1 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/ossupport" + "github.com/ReCasaOS/CasaOS/service" + "github.com/labstack/echo/v4" +) + +// fakeOSService is a box with the os-release, the files and the clock of the test, the rest of the +// service being the real answer of ossupport. +type fakeOSService struct { + service.SystemService + release map[string]string + files []string // the paths that exist on the box, besides os-release + arch string + now time.Time +} + +func (f *fakeOSService) GetSystemOS() ossupport.Report { + return ossupport.Describe(f.release, f.arch, f.now, func(path string) bool { return slices.Contains(f.files, path) }) +} + +// The envelope is the other handlers', and the data is exactly what the spec shows, for the boxes +// that matter: the owner's Debian 11, the same with a table that has gone stale, a Raspberry Pi OS +// 64-bit image (Debian's own os-release, told by /etc/rpi-issue: no claim and no next release), an +// Ubuntu that is ending (to the month), and a Mint, which is not known. +func TestGetSystemOSAnswersWithTheEnvelopeAndTheExactData(t *testing.T) { + original := service.MyService + t.Cleanup(func() { service.MyService = original }) + + for _, tc := range []struct { + name string + box *fakeOSService + want string + }{ + { + "Debian 11", + &fakeOSService{ + release: map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + arch: "amd64", now: time.Date(2026, 10, 10, 9, 30, 0, 0, time.UTC), + }, + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","extended":{"name":"Debian ELTS (Freexian)","ends":"2031-06-30","precision":"day"},"source":"https://www.debian.org/News/2026/20260831"},` + + `"next":{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian 12 (bookworm)","state":"report"}}`, + }, + { + "Debian 11, with a table that has gone stale: it has still ended", + &fakeOSService{ + release: map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, + arch: "amd64", now: time.Date(2028, 5, 1, 9, 30, 0, 0, time.UTC), + }, + `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"amd64","known":true,` + + `"support":{"state":"security_ended","ends":"2026-08-31","precision":"day","source":"https://www.debian.org/News/2026/20260831"}}`, + }, + { + "Raspberry Pi OS 64-bit, which says Debian", + &fakeOSService{ + release: map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm", "PRETTY_NAME": "Debian GNU/Linux 12 (bookworm)"}, + files: []string{"/etc/rpi-issue"}, + arch: "arm64", now: time.Date(2026, 10, 10, 9, 30, 0, 0, time.UTC), + }, + `{"id":"debian","version_id":"12","codename":"bookworm","pretty_name":"Debian GNU/Linux 12 (bookworm)","arch":"arm64","known":false,"variant":"raspberrypi","support":{"state":"unknown"}}`, + }, + { + "Ubuntu 22.04, ending", + &fakeOSService{ + release: map[string]string{"ID": "ubuntu", "ID_LIKE": "debian", "VERSION_ID": "22.04", "VERSION_CODENAME": "jammy", "PRETTY_NAME": "Ubuntu 22.04.5 LTS"}, + arch: "amd64", now: time.Date(2027, 1, 15, 9, 30, 0, 0, time.UTC), + }, + `{"id":"ubuntu","version_id":"22.04","codename":"jammy","pretty_name":"Ubuntu 22.04.5 LTS","arch":"amd64","known":true,` + + `"support":{"state":"ending_soon","ends":"2027-05-31","precision":"month","extended":{"name":"Ubuntu Pro (ESM)","ends":"2032-04-21","precision":"month"},"source":"https://ubuntu.com/about/release-cycle"},` + + `"next":{"id":"ubuntu","version_id":"24.04","codename":"noble","pretty_name":"Ubuntu 24.04 LTS (noble)","state":"report"}}`, + }, + { + "a Linux Mint", + &fakeOSService{ + release: map[string]string{"ID": "linuxmint", "ID_LIKE": "ubuntu debian", "VERSION_ID": "21.3", "VERSION_CODENAME": "virginia", "PRETTY_NAME": "Linux Mint 21.3"}, + arch: "amd64", now: time.Date(2026, 10, 10, 9, 30, 0, 0, time.UTC), + }, + `{"id":"linuxmint","version_id":"21.3","codename":"virginia","pretty_name":"Linux Mint 21.3","arch":"amd64","known":false,"support":{"state":"unknown"}}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + service.MyService = fakeSystemPackageRepository{system: tc.box} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/v1/sys/os", nil) + + if err := GetSystemOS(echo.New().NewContext(request, recorder)); err != nil { + t.Fatalf("GetSystemOS() error = %v", err) + } + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d", recorder.Code) + } + var envelope struct { + Success int `json:"success"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode %q: %v", recorder.Body.String(), err) + } + if envelope.Success != http.StatusOK || envelope.Message != "ok" { + t.Fatalf("envelope = %d %q, want 200 \"ok\"", envelope.Success, envelope.Message) + } + if string(envelope.Data) != tc.want { + t.Fatalf("data:\n got %s\nwant %s", envelope.Data, tc.want) + } + }) + } +} diff --git a/route/v1_test.go b/route/v1_test.go index dff43ed0..718d5f10 100644 --- a/route/v1_test.go +++ b/route/v1_test.go @@ -83,6 +83,31 @@ func TestSkipAccessLog(t *testing.T) { } } +func TestTheOSRouteIsASysRouteBehindTheToken(t *testing.T) { + e, ok := InitV1Router().(*echo.Echo) + if !ok { + t.Fatal("InitV1Router() is not an *echo.Echo") + } + registered := false + for _, r := range e.Routes() { + registered = registered || r.Method+" "+r.Path == "GET /v1/sys/os" + } + if !registered { + t.Error("GET /v1/sys/os is not registered") + } + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/sys/os", nil)) + if recorder.Code != http.StatusUnauthorized { + t.Errorf("GET /v1/sys/os without a token = %d, want 401", recorder.Code) + } + // the route only reads: nothing else answers on the path + recorder = httptest.NewRecorder() + e.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/sys/os", nil)) + if recorder.Code == http.StatusOK { + t.Errorf("POST /v1/sys/os = %d", recorder.Code) + } +} + func TestTheTelemetryAutoUpdateAndAlertsRoutesAreSysRoutesBehindTheToken(t *testing.T) { e, ok := InitV1Router().(*echo.Echo) if !ok { diff --git a/service/system.go b/service/system.go index cff6fc16..10098cab 100644 --- a/service/system.go +++ b/service/system.go @@ -23,6 +23,7 @@ import ( "github.com/ReCasaOS/CasaOS/common" "github.com/ReCasaOS/CasaOS/model" "github.com/ReCasaOS/CasaOS/pkg/config" + "github.com/ReCasaOS/CasaOS/pkg/ossupport" "github.com/ReCasaOS/CasaOS/pkg/utils/common_err" "github.com/ReCasaOS/CasaOS/pkg/utils/httper" "github.com/ReCasaOS/CasaOS/pkg/utils/ip_helper" @@ -62,6 +63,8 @@ type SystemService interface { // OnDockerUpdateQueued sets what is called when a run of the Docker update has its log: it // must return at once. OnDockerUpdateQueued(queued func()) + // GetSystemOS is the operating system and how long its security updates last: it only reads. + GetSystemOS() ossupport.Report GetSystemConfigDebug() []string GetCasaOSLogs(lineNumber int) string UpdateAssist() diff --git a/service/system_os.go b/service/system_os.go new file mode 100644 index 00000000..988e7879 --- /dev/null +++ b/service/system_os.go @@ -0,0 +1,40 @@ +package service + +import ( + "os" + "runtime" + "time" + + commonfile "github.com/ReCasaOS/CasaOS-Common/utils/file" + "github.com/ReCasaOS/CasaOS/pkg/ossupport" +) + +// GetSystemOS is the operating system the box runs and how long its security updates last. It reads +// /etc/os-release, and looks for the files that tell a variant (Raspberry Pi OS, Proxmox...) from the +// plain distribution: no apt, no network, no lock. +func (s *systemService) GetSystemOS() ossupport.Report { + return systemOS(readOSRelease, markerExists, runtime.GOARCH, time.Now()) +} + +// What GetSystemOS reads, as vars, so that a test can say what is on the box: os-release, and the +// files that tell a variant. +var ( + readOSRelease = commonfile.ReadOSRelease + markerExists = pathExists +) + +// pathExists says whether path exists on this box, whatever it is. +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// systemOS is GetSystemOS with what it reads and what day it is given. A file that cannot be read +// is an operating system that is not known: the answer says so, and is not an error. +func systemOS(read func() (map[string]string, error), exists func(path string) bool, arch string, now time.Time) ossupport.Report { + release, err := read() + if err != nil { + release = nil + } + return ossupport.Describe(release, arch, now, exists) +} diff --git a/service/system_os_test.go b/service/system_os_test.go new file mode 100644 index 00000000..704285f1 --- /dev/null +++ b/service/system_os_test.go @@ -0,0 +1,137 @@ +package service + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/ossupport" +) + +// none is a box that has none of the files that tell a variant. +func none(string) bool { return false } + +func TestSystemOSReadsTheReleaseAndTheDay(t *testing.T) { + now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + read := func() (map[string]string, error) { + return map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, nil + } + + got := systemOS(read, none, "amd64", now) + + if !got.Known || got.ID != "debian" || got.VersionID != "11" || got.Arch != "amd64" || got.Support.State != ossupport.SecurityEnded || got.Next == nil || got.Next.VersionID != "12" { + t.Fatalf("systemOS = %+v", got) + } + // the day is the one it was given, and a clock from before the table was written is not believed + if got := systemOS(read, none, "amd64", time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC)); got.Support.State != ossupport.Unknown { + t.Errorf("before the table was written: %s", got.Support.State) + } +} + +func TestSystemOSOfAFileThatCannotBeReadIsNotKnown(t *testing.T) { + read := func() (map[string]string, error) { + // a reader that fails may still hand back a half-read map: it is not to be believed + return map[string]string{"ID": "debian", "VERSION_ID": "11"}, errors.New("read /etc/os-release: permission denied") + } + + asked := false + got := systemOS(read, func(string) bool { asked = true; return true }, "amd64", time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)) + if asked { + t.Error("the box was asked about its files with no os-release to say what it is") + } + + body, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if want := `{"arch":"amd64","known":false,"support":{"state":"unknown"}}`; string(body) != want { + t.Fatalf("systemOS = %s, want %s", body, want) + } +} + +// A box that says Debian and has the marker of a variant is not known, whichever variant it is: the +// answer says which, claims nothing and offers no next release. The files are looked for through the +// seam, so that no real file is needed. +func TestSystemOSOfAVariantIsNotKnown(t *testing.T) { + read := func() (map[string]string, error) { + return map[string]string{"ID": "debian", "VERSION_ID": "11", "VERSION_CODENAME": "bullseye", "PRETTY_NAME": "Debian GNU/Linux 11 (bullseye)"}, nil + } + now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + for variant, path := range map[string]string{ + "raspberrypi": "/etc/rpi-issue", + "armbian": "/etc/armbian-release", + "dietpi": "/boot/dietpi.txt", + "proxmox": "/usr/bin/pveversion", + "openmediavault": "/etc/openmediavault", + } { + t.Run(variant, func(t *testing.T) { + body, err := json.Marshal(systemOS(read, func(p string) bool { return p == path }, "arm64", now)) + if err != nil { + t.Fatal(err) + } + want := `{"id":"debian","version_id":"11","codename":"bullseye","pretty_name":"Debian GNU/Linux 11 (bullseye)","arch":"arm64","known":false,"variant":"` + variant + `","support":{"state":"unknown"}}` + if string(body) != want { + t.Fatalf("systemOS = %s, want %s", body, want) + } + }) + } +} + +// What the seam says is true of the real machine: a file and a directory exist, what is not there +// does not. +func TestPathExists(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "marker") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for path, want := range map[string]bool{dir: true, file: true, filepath.Join(dir, "missing"): false, filepath.Join(file, "below"): false} { + if got := pathExists(path); got != want { + t.Errorf("pathExists(%q) = %v, want %v", path, got, want) + } + // and it is what the method looks with + if got := markerExists(path); got != want { + t.Errorf("markerExists(%q) = %v, want %v", path, got, want) + } + } +} + +// The method reads os-release and looks for the files of a variant through the two things it is +// given: a Debian whose /etc/rpi-issue exists is a Raspberry Pi OS, and a box that has no such file is +// the plain Debian. +func TestGetSystemOSUsesTheFilesOfTheMachine(t *testing.T) { + readBefore, existsBefore := readOSRelease, markerExists + t.Cleanup(func() { readOSRelease, markerExists = readBefore, existsBefore }) + readOSRelease = func() (map[string]string, error) { + return map[string]string{"ID": "debian", "VERSION_ID": "12", "VERSION_CODENAME": "bookworm"}, nil + } + + markerExists = func(path string) bool { return path == "/etc/rpi-issue" } + if got := (&systemService{}).GetSystemOS(); got.Known || got.Variant != "raspberrypi" { + t.Errorf("with /etc/rpi-issue: %+v, want a Raspberry Pi OS that is not known", got) + } + markerExists = func(string) bool { return false } + if got := (&systemService{}).GetSystemOS(); !got.Known || got.Variant != "" { + t.Errorf("with none of the files: %+v, want the plain Debian", got) + } +} + +// The method itself reads the real file of the machine the test runs on: whatever it is, the answer +// is whole, and the architecture is Go's. +func TestGetSystemOSAnswersForThisMachine(t *testing.T) { + got := (&systemService{}).GetSystemOS() + if got.Arch != runtime.GOARCH { + t.Errorf("arch = %q, want %q", got.Arch, runtime.GOARCH) + } + if got.Support.State == "" { + t.Errorf("no state: %+v", got) + } + if got.Known && !strings.Contains("debian ubuntu", got.ID) { + t.Errorf("known with an ID of %q", got.ID) + } +}