diff --git a/README.md b/README.md index db88efc..d2041f5 100644 --- a/README.md +++ b/README.md @@ -41,18 +41,16 @@ Or clone the repo and run the script manually: ```bash git clone https://github.com/LensBridge/agent.git cd agent -chmod +x ./setup.sh -sudo ./setup.sh +./setup.sh # as a user with sudo, not as root ``` -Follow the prompts to set the device up, then reboot. Upon reboot, the device will be at a splash screen waiting for enrollment. Once the device has a network connection, that splash also prints its IP address on screen — use it to SSH in without hunting through DHCP leases. +Follow the prompts to set the device up (you need an admin username and your SSH public key; the board has no password login), then reboot. For unattended installs, pass them as `MB_ADMIN_USER=... MB_ADMIN_SSH_KEY="ssh-ed25519 ..."` in front of `bash`. Upon reboot, the device will be at a splash screen waiting for enrollment. Once the device has a network connection, that splash also prints its IP address on screen — use it to SSH in without hunting through DHCP leases. To enroll the device, run: ```bash -sudo musallahboard-agent enroll \ - --backend \ - --token --backend= +# Easiest: copy the whole command from the admin portal when you issue the token. ``` The device will then be enrolled and the board will load automatically once its first content and board app packages have synced. The device is now ready to be used as a kiosk! @@ -85,13 +83,19 @@ Nothing to do. An enrolled board syncs its content from LensBridge every 30 minu ### Boards without internet -Set the board up and enroll it online as usual, then, while it still has internet: +Set the board up and enroll it online as usual, then turn on the ethernet service port: ```bash -bash setup.sh --service-port [--rtc] # --rtc if a DS3231 clock module is fitted +sudo musallahboard-agent service-port on ``` -That turns the ethernet port into a service port: a laptop or phone plugged straight into it gets an address from the board. Turn it off before plugging the board into a real network: `sudo musallahboard-agent service-port off`. +If a DS3231 clock module is fitted, also re-run the setup once with `--rtc`, while the board has internet: + +```bash +curl -fsSL https://raw.githubusercontent.com/lensbridge/agent/main/setup.sh | bash -s -- --service-port --rtc +``` + +The service port turns the ethernet port into a service port: a laptop or phone plugged straight into it gets an address from the board. Turn it off before plugging the board into a real network: `sudo musallahboard-agent service-port off`. To update the board, get the packages first, while you have internet: @@ -100,7 +104,7 @@ To update the board, get the packages first, while you have internet: Then use any of: -1. **USB stick.** Copy the `.mbu` files to the stick (its root, or a `MusallahBoard/` folder) and plug it into the board. The screen shows progress; remove the stick when it says "Update complete". One stick can carry content for several boards: each board takes only its own. +1. **USB stick.** Copy the `.mbu` files to the stick (its root, or a `MusallahBoard/` folder) and plug it into the board. The screen shows progress, and says "You can remove the USB stick" as soon as it has copied what it needs, usually within seconds. One stick can carry content for several boards: each board takes only its own. 2. **Laptop.** Plug into the board's ethernet port and open `http://10.77.0.1/` in a browser, or run `mbpush ...`. `mbpush status` shows what is installed and how far the board's clock is off; an upload also sets the clock from the laptop's. For a board reachable only over SSH: `mbpush --ssh admin@host ...`. 3. **Android app.** Downloads the packages while it has signal, then uploads them over a USB ethernet adapter. diff --git a/cmd/agent/board.go b/cmd/agent/board.go index 71e4552..ec3214c 100644 --- a/cmd/agent/board.go +++ b/cmd/agent/board.go @@ -3,16 +3,19 @@ package main import ( "context" "encoding/json" + "errors" "log/slog" "net" "os" "runtime" "strings" "sync" + "sync/atomic" "time" "github.com/coreos/go-systemd/v22/daemon" + "github.com/LensBridge/agent/internal/agentupdate" "github.com/LensBridge/agent/internal/boardsync" "github.com/LensBridge/agent/internal/cdp" "github.com/LensBridge/agent/internal/clock" @@ -23,8 +26,10 @@ import ( "github.com/LensBridge/agent/internal/keystore" "github.com/LensBridge/agent/internal/kioskurl" "github.com/LensBridge/agent/internal/localserver" + "github.com/LensBridge/agent/internal/notice" "github.com/LensBridge/agent/internal/store" "github.com/LensBridge/agent/internal/trust" + "github.com/LensBridge/agent/internal/updates" "github.com/LensBridge/agent/internal/updatescreen" "github.com/LensBridge/agent/internal/uploadserver" "github.com/LensBridge/agent/internal/version" @@ -50,15 +55,21 @@ func startBoard(ctx context.Context, logger *slog.Logger, cfg *config.Config, sa hub := &events.Hub{} cdpClient := cdp.New("") screen := updatescreen.New(cdpClient, strings.TrimSuffix(localserver.BaseURL, "/"), logger) + var reporter *agentupdate.Reporter imp := importer.New(importer.Deps{ Layout: layout, DeviceID: cfg.DeviceID, AgentVersion: version.Version, Ring: func() (*trust.Ring, error) { return trust.LoadRing(trust.DefaultPath) }, Screen: screen, + AgentStaged: func(n notice.Notice) { reporter.Staged(n) }, Events: hub, Logger: logger, }) + reporter = agentupdate.New(agentupdate.Deps{ + Layout: layout, Version: version.Version, Screen: screen, + Banner: hub.Notice, Resume: imp.AgentNotReplaced, Logger: logger, + }) keeper := clock.New(layout, logger) priv, keyErr := keystore.Load(cfg.KeyPath) @@ -66,14 +77,28 @@ func startBoard(ctx context.Context, logger *slog.Logger, cfg *config.Config, sa logger.Error("device key unreadable: serving installed content only (no sync, no remote commands)", "err", keyErr, "keyPath", cfg.KeyPath) } + hour, minute := cfg.UpdateTime() var syncer *boardsync.Syncer + sched := updates.New(updates.Deps{ + Layout: layout, Importer: imp, Hour: hour, Minute: minute, AutoUpdate: cfg.AutoUpdate(), Logger: logger, + Changed: func(info updates.Info) { hub.UpdatesChanged(info) }, + Check: func(ctx context.Context) error { + if syncer == nil { + return errors.New("this board cannot reach the release channels (device key unreadable)") + } + return syncer.CheckChannels(ctx) + }, + }) if keyErr == nil { syncer = boardsync.New(boardsync.Deps{ - Cfg: cfg, Key: priv, Layout: layout, Importer: imp, + Cfg: cfg, Key: priv, Layout: layout, Importer: imp, Updates: sched, AgentVersion: version.Version, Logger: logger, }) } + // The backend connection starts after the local server; its state is + // read through this once it exists. + var backend atomic.Pointer[wsclient.Client] local := localserver.New(localserver.Deps{ Layout: layout, DeviceID: cfg.DeviceID, AgentVersion: version.Version, Hub: hub, Sync: func() any { @@ -89,7 +114,18 @@ func startBoard(ctx context.Context, logger *slog.Logger, cfg *config.Config, sa return syncer.Weather() }, UpdateActive: screen.Active, - Logger: logger, + Updates: sched.Info, + Clock: keeper.Info, + Backend: func() any { + if ws := backend.Load(); ws != nil { + s := ws.State() + return &s + } + return nil + }, + ServicePort: cfg.ServicePort(), + USBImport: cfg.USBImport(), + Logger: logger, }) ln, err := net.Listen("tcp", localserver.ListenAddr) if err != nil { @@ -112,10 +148,16 @@ func startBoard(ctx context.Context, logger *slog.Logger, cfg *config.Config, sa _, _ = daemon.SdNotify(false, "STATUS=serving the board on "+localserver.ListenAddr) logInstalled(logger, layout, cfg) - goRun(wg, func() { screen.Resume(ctx) }) goRun(wg, func() { watchKiosk(ctx, logger, cdpClient) }) - goRun(wg, func() { imp.RunInbox(ctx) }) + goRun(wg, func() { + // First say how an agent update that restarted us went: the rest of + // its batch may be waiting in the inbox, and belongs on the same + // screen. + reporter.Startup(ctx) + imp.RunInbox(ctx) + }) goRun(wg, func() { keeper.Run(ctx) }) + goRun(wg, func() { sched.Run(ctx) }) if cfg.ServicePort() { startUploadServer(ctx, logger, cfg, layout, imp, keeper, screen, wg) @@ -123,7 +165,9 @@ func startBoard(ctx context.Context, logger *slog.Logger, cfg *config.Config, sa if syncer != nil { goRun(wg, func() { syncer.Run(ctx) }) - startCommandChannel(ctx, logger, cfg, priv, safeMode, cdpClient, syncer, wg) + ws := startCommandChannel(ctx, logger, cfg, priv, safeMode, cdpClient, syncer, sched, + func() any { return local.Status().Board() }, wg) + backend.Store(ws) } return nil } @@ -142,6 +186,7 @@ func startUploadServer(ctx context.Context, logger *slog.Logger, cfg *config.Con return uploadserver.ClockReport{DriftSeconds: r.DriftSeconds, Adjusted: r.Adjusted, Note: r.Note} }, RTCPresent: keeper.RTCPresent, + ClockInfo: keeper.Info, UpdateActive: screen.Active, Logger: logger, }) @@ -154,11 +199,14 @@ func startUploadServer(ctx context.Context, logger *slog.Logger, cfg *config.Con } // startCommandChannel holds the backend WebSocket (telemetry and remote -// commands) open. config.refresh also asks the content syncer for a sync now. +// commands) open. config.refresh also asks the content syncer for a sync now; +// update.install_now checks the release channels and installs at once. func startCommandChannel(ctx context.Context, logger *slog.Logger, cfg *config.Config, priv []byte, - safeMode bool, cdpClient *cdp.Client, syncer *boardsync.Syncer, wg *sync.WaitGroup) { + safeMode bool, cdpClient *cdp.Client, syncer *boardsync.Syncer, sched *updates.Scheduler, + boardReport func() any, wg *sync.WaitGroup) *wsclient.Client { wsClient := wsclient.New(cfg, priv, logger, version.Version, safeMode) wsClient.SetPageProber(cdpClient) + wsClient.SetBoardReport(boardReport) registry := commands.NewRegistry() registry.Register(&commands.ChromeReload{CDP: cdpClient}) registry.Register(&commands.ChromeScreenshot{CDP: cdpClient}) @@ -166,9 +214,11 @@ func startCommandChannel(ctx context.Context, logger *slog.Logger, cfg *config.C registry.Register(&commands.KioskRestart{}) registry.Register(&commands.SystemReboot{}) registry.Register(&commands.LogsTail{}) + registry.Register(&commands.UpdateInstallNow{Run: sched.CheckAndInstall}) logger.Info("command handlers registered", "kinds", registry.Kinds()) wsClient.SetCommandHandler(commands.NewDispatcher(registry, logger).Handle) goRun(wg, func() { wsClient.Run(ctx) }) + return wsClient } // watchKiosk navigates Chromium back to the board if it lands on its own diff --git a/cmd/agent/import_cli.go b/cmd/agent/import_cli.go index 031bc7a..500c8c6 100644 --- a/cmd/agent/import_cli.go +++ b/cmd/agent/import_cli.go @@ -79,11 +79,10 @@ func runImport(args []string) { if !ok { fail("no result for %s after %s. Is musallahboard-agent running? (systemctl status musallahboard-agent)", name, importWait) } - mark := "OK " if res.Action == importer.ActionRejected { - mark, rejected = "NO ", true + rejected = true } - fmt.Printf(" %s %s: %s\n", mark, res.Action, res.Message) + printResult(res.Result) } if rejected { fmt.Println("Anything refused was not installed; the board keeps what it had.") @@ -91,6 +90,22 @@ func runImport(args []string) { } } +// printResult prints one package's outcome, with the technical detail of a +// refusal under it. +func printResult(r importer.Result) { + mark := "OK " + switch r.Action { + case importer.ActionRejected: + mark = "NO " + case importer.ActionUnchanged, importer.ActionOutdated, importer.ActionSkipped: + mark = " - " + } + fmt.Printf(" %s %s: %s\n", mark, r.File, r.Message) + if r.Detail != "" { + fmt.Printf(" (%s)\n", r.Detail) + } +} + func waitResult(dir, name string, deadline time.Time, hinted *bool) (importer.InboxResult, bool) { path := filepath.Join(dir, name+".json") start := time.Now() diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 9351604..d11314c 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "os/signal" + "strings" "sync" "syscall" "time" @@ -55,6 +56,8 @@ func main() { runStatus(os.Args[2:]) case "import": runImport(os.Args[2:]) + case "update": + runUpdate(os.Args[2:]) case "service-port": runServicePort(os.Args[2:]) case "trust": @@ -63,6 +66,8 @@ func main() { runSelfUpdate(os.Args[2:]) case "usb-import": runUSBImport(os.Args[2:]) + case "splash": + runSplash(os.Args[2:]) case "version", "-v", "--version": fmt.Printf("musallahboard-agent %s\n", version.Version) case "-h", "--help", "help": @@ -85,17 +90,20 @@ Usage: Administration (need sudo): musallahboard-agent import ... | - Install signed update packages + musallahboard-agent update now Check for a new app or agent and install it now musallahboard-agent service-port on|off eth0 service port + upload server at http://10.77.0.1/ musallahboard-agent trust show|add|remove|fetch Keys this board accepts packages from Run by systemd (root): musallahboard-agent selfupdate apply Install a staged agent update, with rollback musallahboard-agent usb-import Copy update packages from a USB stick + musallahboard-agent splash install [path] Install the enrollment splash page Enroll flags: --token One-time enrollment token from the admin portal --backend Backend base URL (e.g. https://backend.utmmsa.ca) --config Path to write agent config (default: ` + defaultConfigPath + `) + --force Enroll a board that is already enrolled (it gets a new identity) `) } @@ -104,24 +112,44 @@ func runEnroll(args []string) { token := fs.String("token", "", "one-time enrollment token") backend := fs.String("backend", "", "backend base URL") configPath := fs.String("config", defaultConfigPath, "path to write config") + force := fs.Bool("force", false, "enroll again, replacing this board's identity") if err := fs.Parse(args); err != nil { os.Exit(2) } if *token == "" || *backend == "" { - fmt.Fprintln(os.Stderr, "error: --token and --backend are required") + fmt.Fprintln(os.Stderr, "usage: sudo musallahboard-agent enroll --token= --backend=") + fmt.Fprintln(os.Stderr, "Copy the whole command from the admin portal: Devices, Enroll a board.") os.Exit(2) } + requireRoot("enroll") + + // Enrolling again gives the board a new identity; the old device stays + // in the portal, orphaned, until someone revokes it. Only on purpose. + if old, err := config.Load(*configPath); err == nil && !*force { + fmt.Fprintf(os.Stderr, "This board is already enrolled (device %s).\n", old.DeviceID) + fmt.Fprintln(os.Stderr, "Enrolling again gives it a new identity; revoke the old device in the portal afterwards.") + fmt.Fprintln(os.Stderr, "To go ahead, run the same command with --force.") + os.Exit(1) + } - logger := newLogger() - if err := enroll.Run(context.Background(), logger, enroll.Params{ + fmt.Println("Enrolling this board with", strings.TrimRight(*backend, "/"), "...") + if err := enroll.Run(context.Background(), slog.New(plainHandler{os.Stdout}), enroll.Params{ Token: *token, BackendURL: *backend, ConfigPath: *configPath, AgentVersion: version.Version, }); err != nil { - logger.Error("enrollment failed", "err", err) + fmt.Fprintf(os.Stderr, "Enrollment failed: %v\n", err) + fmt.Fprintln(os.Stderr, "Tokens can be used once and expire; if this one was used or has expired, issue a new one in the portal.") os.Exit(1) } + cfg, err := config.Load(*configPath) + if err != nil { + fail("enrolled, but the config it wrote cannot be read: %v", err) + } + fmt.Printf("Enrolled as device %s.\n", cfg.DeviceID) + fmt.Println("The board switches from the enrollment screen to MusallahBoard within a few seconds, then") + fmt.Println("downloads its board app and content. Check on it with: musallahboard-agent status") } func runDaemon() { @@ -222,7 +250,7 @@ func awaitEnrollment(ctx context.Context, logger *slog.Logger) *config.Config { // Chromium may not be up yet, or may be mid-restart, and once enrolled // the board replaces the splash entirely — a push that finds no hook is // the normal case, not an error worth a log line every tick. - switch err := splash.PushNetInfo(ctx, cdpClient, info); { + switch err := splash.PushNetInfo(ctx, cdpClient, info, version.Version); { case err == nil, errors.Is(err, splash.ErrNoSplash): default: logger.Debug("could not update enrollment splash", "err", err) diff --git a/cmd/agent/plainlog.go b/cmd/agent/plainlog.go new file mode 100644 index 0000000..2d2b53f --- /dev/null +++ b/cmd/agent/plainlog.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" +) + +// plainHandler writes log records as plain sentences for a person at a +// terminal: the message only, warnings and errors marked, no JSON. The +// daemon logs JSON for the journal; one-off commands (enroll) talk to people. +type plainHandler struct{ w io.Writer } + +func (h plainHandler) Enabled(_ context.Context, l slog.Level) bool { return l >= slog.LevelInfo } + +func (h plainHandler) Handle(_ context.Context, r slog.Record) error { + prefix := " " + switch { + case r.Level >= slog.LevelError: + prefix = " Error: " + case r.Level >= slog.LevelWarn: + prefix = " Warning: " + } + _, err := fmt.Fprintln(h.w, prefix+r.Message) + return err +} + +func (h plainHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h plainHandler) WithGroup(string) slog.Handler { return h } diff --git a/cmd/agent/serviceport_cli.go b/cmd/agent/serviceport_cli.go index f1a40d9..ea877f5 100644 --- a/cmd/agent/serviceport_cli.go +++ b/cmd/agent/serviceport_cli.go @@ -115,7 +115,9 @@ const ( func servicePortOn(force bool) { if !nmConnectionExists(serviceConn) { - fail("the NetworkManager connection %q does not exist, so a laptop could not reach this board.\n Provision it first: bash setup.sh --service-port", serviceConn) + fail("the NetworkManager connection %q does not exist, so a laptop could not reach this board.\n"+ + " It is created by the MusallahBoard setup; re-run it with --service-port:\n"+ + " curl -fsSL https://raw.githubusercontent.com/lensbridge/agent/main/setup.sh | bash -s -- --service-port", serviceConn) } if out, err := runCmd("nmcli", "connection", "modify", serviceConn, "connection.autoconnect", "yes", "connection.autoconnect-priority", servicePortPriority); err != nil { diff --git a/cmd/agent/splash_cli.go b/cmd/agent/splash_cli.go new file mode 100644 index 0000000..307c60a --- /dev/null +++ b/cmd/agent/splash_cli.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/packaging" +) + +// defaultSplashPath is where the kiosk loads the enrollment splash from +// (packaging/start-kiosk.sh). +const defaultSplashPath = "/usr/share/musallahboard/waiting.html" + +// runSplash is `splash install [dir-or-waiting.html path]`: write the kiosk's +// local pages embedded in this binary: the enrollment splash (waiting.html) +// and the page it waits on while the agent starts (starting.html). setup.sh +// runs it once the binary is in place, and the self-updater after each +// update, so the pages always match the agent. +func runSplash(args []string) { + if len(args) < 1 || args[0] != "install" || len(args) > 2 { + fmt.Fprintln(os.Stderr, "usage: musallahboard-agent splash install [path]") + os.Exit(2) + } + path := defaultSplashPath + if len(args) == 2 { + path = args[1] + } + requireRoot("splash install") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + fail("%v", err) + } + if err := fsutil.WriteAtomic(path, packaging.WaitingHTML, 0o644); err != nil { + fail("could not write %s: %v", path, err) + } + starting := filepath.Join(filepath.Dir(path), "starting.html") + if err := fsutil.WriteAtomic(starting, packaging.StartingHTML, 0o644); err != nil { + fail("could not write %s: %v", starting, err) + } +} diff --git a/cmd/agent/status_cli.go b/cmd/agent/status_cli.go index ba6713f..c945f07 100644 --- a/cmd/agent/status_cli.go +++ b/cmd/agent/status_cli.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/LensBridge/agent/internal/cdp" + "github.com/LensBridge/agent/internal/clock" "github.com/LensBridge/agent/internal/localserver" "github.com/LensBridge/agent/internal/store" "github.com/LensBridge/agent/internal/trust" @@ -114,9 +116,50 @@ func systemZone() string { return name } +func onOff(on bool) string { + if on { + return "on" + } + return "off" +} + +// localTime renders an RFC 3339 time as the board's local wall clock. +func localTime(v any) string { + s, _ := v.(string) + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return t.Local().Format("Mon 2 Jan 15:04") +} + +// kioskShowing asks Chromium, over its debugging port, what it is showing. +func kioskShowing() string { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + t, err := cdp.New("").FirstPageTarget(ctx) + if err != nil { + return "NOTHING: the kiosk browser is not running (systemctl status musallahboard-kiosk)" + } + switch u := t.URL; { + case strings.Contains(u, "/_mb/updating"): + return "the update screen" + case strings.HasPrefix(u, "http://127.0.0.1:8080"): + return "MusallahBoard" + case strings.Contains(u, "starting.html"): + return "the starting page, waiting for the agent" + case strings.Contains(u, "waiting.html"): + return "the enrollment screen" + case strings.HasPrefix(u, "chrome-error:"): + return "A BROWSER ERROR PAGE (the agent recovers it within a minute)" + default: + return u + } +} + func printStatus(st cliStatus) { row := func(k, format string, args ...any) { - fmt.Printf(" %-13s %s\n", k+":", fmt.Sprintf(format, args...)) + fmt.Printf(" %-18s %s\n", k+":", fmt.Sprintf(format, args...)) } row("Device", "%s", st.DeviceID) if st.Daemon { @@ -124,6 +167,20 @@ func printStatus(st cliStatus) { } else { row("Agent", "%s (NOT running: systemctl status musallahboard-agent)", version.Version) } + if st.Daemon { + row("Screen", "%s", kioskShowing()) + switch b, _ := st.Backend.(map[string]any); { + case b == nil: + row("LensBridge", "not connected: no device key") + case b["connected"] == true: + row("LensBridge", "connected since %s", localTime(b["since"])) + case b["lastError"] != nil && b["lastError"] != "": + row("LensBridge", "NOT connected: %v", b["lastError"]) + default: + row("LensBridge", "connecting") + } + } + row("Offline updates", "USB sticks %s; ethernet service port %s", onOff(st.USBImport), onOff(st.ServicePort)) if st.App != nil { row("Board app", "%s", st.App.Version) } else { @@ -163,12 +220,56 @@ func printStatus(st cliStatus) { row("Sync", "not yet attempted") } } + if u := st.Updates; u != nil { + switch { + case u.Installing: + row("Updates", "installing now") + case !u.AutoUpdate && len(u.Available) == 0: + row("Updates", "automatic updates are off (auto_update = false)") + case u.LastCheckError != "" && len(u.Available) == 0: + row("Updates", "CHECK FAILING: %s", u.LastCheckError) + case len(u.Available) == 0 && u.LastCheckAt == nil: + row("Updates", "not checked yet (first check 2 minutes after start)") + case len(u.Available) == 0: + row("Updates", "up to date (checked %s; installs at %s)", localTime(*u.LastCheckAt), u.InstallTime) + default: + var names []string + for _, a := range u.Available { + names = append(names, a.Description) + } + at := "" + if u.InstallAt != nil { + at = localTime(*u.InstallAt) + } + row("Updates", "%s waiting, installs %s", strings.Join(names, " and "), at) + row("", "Install now: sudo musallahboard-agent update now") + } + } + if o := st.LastAgentUpdate; o != nil { + row("Last agent update", "%s, %s", localTime(o.At), o.Status) + row("", "%s", o.Message) + } row("Trust", "%d content key(s), %d release key(s)", st.Trust.ContentKeys, st.Trust.ReleaseKeys) if st.Trust.ContentKeys == 0 { row("", "No content key: run `sudo musallahboard-agent trust fetch` while online.") } t, _ := time.Parse(time.RFC3339, st.Clock.Now) row("Clock", "%s (%s)", t.Format("Mon 2 Jan 2006 15:04:05 MST"), st.Clock.Timezone) + if c := st.Status.Clock; c != nil { + switch c.Source { + case clock.SourceNTP: + row("", "synchronised over the internet") + case clock.SourceRTC: + row("", "kept by the hardware clock") + case clock.SourceUploader: + row("", "set from a laptop or phone since boot") + case clock.SourceStarting: + row("", "not confirmed yet (just booted)") + default: + row("", "NOT CONFIRMED since boot: it may be wrong. Connect the board to the internet,") + row("", "fit an RTC (setup.sh --rtc), or send an update from http://10.77.0.1/") + } + } if st.RTC { row("RTC", "present (%s)", rtcDevice) } else { diff --git a/cmd/agent/update_cli.go b/cmd/agent/update_cli.go new file mode 100644 index 0000000..2af8417 --- /dev/null +++ b/cmd/agent/update_cli.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/updates" +) + +// updateWait is how long `update now` waits: a download on a slow link, then +// an install. +const updateWait = 15 * time.Minute + +// runUpdate is `update now` (docs/architecture.md, section 9.4): check the +// release channels and install a new app or agent at once instead of in the +// board's quiet window. The daemon does the work; this asks it to by +// dropping a request file, as `import` does with the inbox, and prints its +// answer. +func runUpdate(args []string) { + if len(args) != 1 || args[0] != "now" { + fmt.Fprintln(os.Stderr, "usage: musallahboard-agent update now") + os.Exit(2) + } + requireRoot("update now") + l := store.Default() + if err := os.MkdirAll(l.Updates(), 0o750); err != nil { + fail("could not create %s: %v", l.Updates(), err) + } + chownLike(l.Updates(), l.Root) + if err := os.Remove(l.UpdateRequestResult()); err != nil && !errors.Is(err, fs.ErrNotExist) { + fail("%v", err) + } + if err := fsutil.WriteAtomic(l.UpdateRequest(), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o640); err != nil { + fail("could not ask the agent: %v", err) + } + chownLike(l.UpdateRequest(), l.Root) + fmt.Println("Asked the agent to check for updates and install them now ...") + + deadline := time.Now().Add(updateWait) + for time.Now().Before(deadline) { + raw, err := os.ReadFile(l.UpdateRequestResult()) + if err != nil { + time.Sleep(time.Second) + continue + } + var out updates.Outcome + if err := json.Unmarshal(raw, &out); err != nil { + time.Sleep(time.Second) + continue + } + printUpdateOutcome(out) + return + } + fail("no answer after %s. Is musallahboard-agent running? (systemctl status musallahboard-agent)", updateWait) +} + +func printUpdateOutcome(out updates.Outcome) { + if len(out.Results) == 0 { + if out.CheckError != "" { + // Nothing was installed, and there is no telling whether that is + // because nothing is newer: do not say "up to date". + fmt.Printf(" Could not check for updates: %s\n", out.CheckError) + fmt.Println(" Nothing was installed. Check the board's internet connection and try again.") + os.Exit(1) + } + fmt.Println(" Nothing to install: the board app and agent are up to date.") + return + } + if out.CheckError != "" { + fmt.Printf(" Could not check for updates: %s\n", out.CheckError) + fmt.Println(" Installed what was already downloaded:") + } + rejected := false + for _, r := range out.Results { + if r.Action == importer.ActionRejected { + rejected = true + } + printResult(r) + } + if rejected { + os.Exit(1) + } +} diff --git a/cmd/agent/usbimport.go b/cmd/agent/usbimport.go index 74ba690..f14be7a 100644 --- a/cmd/agent/usbimport.go +++ b/cmd/agent/usbimport.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/LensBridge/agent/internal/config" + "github.com/LensBridge/agent/internal/notice" "github.com/LensBridge/agent/internal/usbimport" ) @@ -31,7 +32,10 @@ func runUSBImport(args []string) { return } if !cfg.USBImport() { - return // usb_import = false: quietly ignore every stick + fmt.Printf("USB import: ignoring %s: usb_import is off in the agent config\n", args[0]) + usbimport.New().Announce(args[0], notice.New(notice.Neutral, "USB updates are turned off", + "This board is set up not to read USB sticks")) + return } ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) diff --git a/docs/architecture.md b/docs/architecture.md index 22c3444..e84ffe6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -260,6 +260,8 @@ packer (`scripts/package-mbu.mjs`) producing the same format. | `/var/lib/musallahboard/agent/last-update.json` | root | outcome of the last self-update | | `/var/lib/musallahboard/inbox/` | daemon 0770 | `*.mbu` dropped by the CLI and USB helper | | `/var/lib/musallahboard/inbox/results/.json` | daemon | outcome per inbox file | +| `/var/lib/musallahboard/updates/{agent,app}.mbu` | daemon 0750 | software from the release channels, waiting for its install window (section 9.4) | +| `/var/lib/musallahboard/updates/install-now[.result.json]` | daemon | `update now` request from the CLI, and the daemon's answer | The daemon keeps the current and previous content bundle and app release and prunes the rest; media not referenced by a kept bundle is deleted. @@ -319,7 +321,13 @@ the bundle's `timezone`; serve `firstDay` if `today < firstDay`, `lastDay` if }, "today": "2026-09-30", "servingDay": "2026-09-30", "daysRemaining": 7, "staleDays": 0, "sync": { "enabled": true, "lastSuccessAt": "…", "lastAttemptAt": "…", "lastError": null }, - "update": { "active": false } + "update": { "active": false }, + "updates": { + "available": [ { "type": "app", "version": "2.2.0", "description": "board app 2.2.0" } ], + "installTime": "23:00", + "installAt": "2026-09-30T23:00:00-04:00", + "installing": false + } } ``` @@ -327,7 +335,11 @@ the bundle's `timezone`; serve `firstDay` if `today < firstDay`, `lastDay` if `daysRemaining` and `staleDays` are then `null` and `today` is in the system zone. `source` is one of `sync`, `usb`, `upload`, `cli`. `sync.lastError` is a short human string or `null`. If installed content cannot be read, `content` is -`null` and an `error` string is added. +`null` and an `error` string is added. `updates` (section 9.4) lists software +waiting for its install window; `installAt` is `null` when nothing waits, and +the current time when the window is open or the board has no app yet. The page +puts "Update available: ... will be installed at 11:00 PM tonight" on its +ticker while `available` is not empty. **`/api/local/events`** (SSE, `text/event-stream`, `no-store`): @@ -336,6 +348,11 @@ short human string or `null`. If installed content cannot be read, `content` is payload in place. - `event: app` with `data: {"version": "…"}` after a new app release is installed. The page reloads itself. +- `event: updates` with the `updates` status object whenever an update starts + or stops waiting, or starts installing. The page re-reads the status. +- `event: notice` with `{"tone", "headline", "lines", "seconds"}`: an update + outcome to show as a banner over the board for `seconds` (0: until the next + notice replaces it). See section 8. - A comment line (`: ping`) every 25 s keeps the connection alive. `EventSource` gives up for good on a non-200 answer (for example while the @@ -356,8 +373,9 @@ Per package: ("for another board"), not as a failure: one USB stick can serve many boards. 2. **Decide** against `state.json`: newer -> install; equal -> `unchanged`; - older -> `rejected` ("older than what is installed"). An agent version in - `agentVersionsRejected` is `rejected` ("failed to start last time"). + older -> `outdated` ("The board already has newer content"). Neither is a + failure: an old USB stick at a board that synced since is the normal case. + An agent version in `agentVersionsRejected` is `rejected`. 3. **Install**: - content: move media into the store (verified hash, fsync), write the bundle directory, fsync, rename into `bundles/`, swap `current`, record the @@ -373,24 +391,45 @@ A batch processes `agent` packages first, then `app`, then `content`. When an agent package is staged, the rest of the batch stays in the inbox and is processed by the new agent after the restart. -**Update screen.** For `usb`, `upload` and `cli` batches containing at least one -package that is not skipped, and for any app or agent install from any source, -the daemon navigates the kiosk (CDP) to `/_mb/updating` and drives it through -`window.mbUpdate` (`caption(text)`, `complete({seconds, headline, message, caption})`), -then navigates back to `/` after the countdown. Background content sync from -the backend is silent: an admin editing a poster must not put "Working on -updates" on every screen. On failure the screen shows -`complete({headline: "Update not installed", message: "Returning to MusallahBoard", caption: })`. +**What the people at the board see.** Every batch ends in one *notice* +(`internal/notice`): a tone (`ok`, `neutral`, `problem`), a headline and at +most three plain-language lines, more folded into "and N more". Lines never +name files; technical reasons go to the logs and the result's `detail`. + +| Outcome | Headline | Lines | +|---|---|---| +| something installed | "Update complete" | "Board app 2.1.0", "New content, through Wednesday, October 7" | +| installed, something refused | "Some updates were not installed" | the refusals, then what installed | +| nothing installed, something refused | "Update not installed" | "An update is not signed for this board" | +| nothing needed doing | "Already up to date" | "The board already has newer content" | +| only other boards' content | "Nothing here is for this board" | | + +*Update screen or banner.* The update screen (`/_mb/updating`) goes up only +when something is about to be installed: content, app or agent from `usb`, +`upload` or `cli`, and app or agent from `sync`. The daemon navigates the +kiosk there over CDP, drives it through `window.mbUpdate` +(`caption(text)` while working, `complete({tone, headline, lines, seconds, +message})` at the end) and navigates back to `/` after the countdown. Each +switch is a dip to black: the daemon fades the page on screen to black (a +veil injected over CDP, 600 ms) and both pages fade in from black on load. + +Everything else a person caused is a *banner* over the running board, sent +as the `notice` event on `/api/local/events` and drawn by the board app: a +progress banner as soon as a batch starts ("Checking updates · From the USB +stick"), then its outcome when nothing was installed. Background content sync +is silent either way: an admin editing a poster must not interrupt every +screen. Results are JSON objects per package: ```json { "file": "musallahboard-app-2.1.0.mbu", "type": "app", "version": "2.1.0", - "action": "installed", "message": "Installed board app 2.1.0" } + "action": "installed", "message": "Board app 2.1.0" } ``` -`action` is one of `installed`, `unchanged`, `staged` (agent, pending restart), -`skipped`, `rejected`. +`action` is one of `installed`, `staged` (agent, pending restart), +`unchanged`, `outdated`, `skipped` (another board's content), `rejected` +(with `detail`), `queued` (behind a staged agent). ## 9. Transports @@ -436,6 +475,14 @@ The backend rejects: unknown or revoked device (`401`), timestamp more than 5 minutes from its clock (`401`), bad signature (`401`). Replays inside the window only repeat an idempotent read. +**Board report.** Every heartbeat on the backend WebSocket carries +`telemetry.board` (`localserver.BoardReport`): `appVersion`, `content` +(`firstDay`, `lastDay`, `createdAt`, `source`, `installedAt`, +`daysRemaining`, `staleDays`), `updates` (as in the status), `lastAgentUpdate`, +`clock` (`source`, `trusted`), `syncError` and `error`. The backend keeps the +latest per device and the admin portal shows it: a board nobody is standing +in front of still says what it runs, what it shows, and what is wrong. + ### 9.3 Backend endpoints (additions) | Endpoint | Auth | Returns | @@ -460,10 +507,28 @@ start) two channel URLs, each returning: { "version": "2.1.0", "url": "https://…/musallahboard-app-2.1.0.mbu", "sha256": "…", "bytes": 1234567 } ``` -If `version` is newer than what is installed (and not rejected), it downloads -`url` (size-capped, sha256-checked) and imports it with source `sync`. The -channel file is only a pointer; the package's own signature and the monotonic -rules are what make it safe. Defaults: +If `version` is newer than what is installed and what is already waiting (and +not rejected), it downloads `url` (size-capped, sha256-checked), verifies it +and checks it would install, and keeps it in `updates/` instead of installing +it. The channel file is only a pointer; the package's own signature and the +monotonic rules are what make it safe. The agent channel is checked first: an +app needing a newer local API than this agent serves only waits when a new +enough agent waits with it. + +Waiting updates install together, as one batch with source `sync` (so the +update screen shows), in the board's install window: from `update_time` +(default `23:00`, in the installed content's time zone, else the system's) for +4 hours. A board that was off at 23:00 but on by 03:00 still updates that +night. Two exceptions install at once: a board with no app installed (it has +nothing else to show), and `update now`: + +- `sudo musallahboard-agent update now` on the board, or +- the `update.install_now` remote command from the admin portal, + +checks both channels and installs whatever is new or waiting, whatever the +time. Waiting updates survive a restart; one that no longer applies (installed +from a USB stick meanwhile) is dropped. Updates carried to the board (USB, +upload, CLI `import`) never wait. Defaults: - `app_channel_url = "https://github.com/LensBridge/MusallahBoard/releases/latest/download/app-channel.json"` - `agent_channel_url = "https://github.com/LensBridge/agent/releases/latest/download/agent-channel-.json"` @@ -479,7 +544,7 @@ When `service_port` is on, the daemon listens on `10.77.0.1:80` (bound with |---|---| | `GET /` | A small self-contained upload page: pick or drop `.mbu` files, see progress and per-package results, see board status and clock drift. | | `GET /api/status` | `{"deviceId", "agentVersion", "app", "content", "today", "daysRemaining", "staleDays", "clock": {"unix", "timezone"}, "rtc": bool, "update": {"active": bool}}` | -| `POST /api/import` | `multipart/form-data`, one or more `package` parts (each at most 512 MiB, total at most 1 GiB). Optional header `X-MB-Client-Time: `. Each part is streamed to the inbox, then processed as one batch (source `upload`). Response `200 {"results": [...], "clock": {"driftSeconds": n, "adjusted": bool, "note": "…"}}`. Only one import runs at a time; a second gets `409`. | +| `POST /api/import` | `multipart/form-data`, one to 16 `package` parts (each at most 512 MiB, total at most 1 GiB). Optional header `X-MB-Client-Time: `. Each part is streamed to the inbox, then processed as one batch (source `upload`). Response `200 {"results": [...], "notice": {...}, "restarting": bool, "agentVersion": "…", "clock": {"driftSeconds": n, "adjusted": bool, "note": "…"}}`. Only one import runs at a time; a second gets `409`. | Errors are `{"message": "..."}` with a 4xx status. `driftSeconds` is the board's clock minus the uploader's, before any correction (positive: the board @@ -517,9 +582,17 @@ folder on it, and plug it into the board. with `ro,nosuid,nodev,noexec,noatime`; copies at most 16 regular files (no symlinks) named `*.mbu`, each at most 512 MiB, into the inbox; unmounts; then waits up to 15 minutes for the daemon's results and logs them. -- The daemon shows the update screen while it works; the stick can be removed - once the screen says "Update complete". -- `usb_import = false` in `agent.toml` disables it (the helper exits at once). +- The helper tells the board what it is doing through banners: it drops a + notice in the inbox (`inbox/usb-.notice.json`) that the daemon shows. + "Reading USB stick" as soon as the stick is seen; then either the batch's + own outcome (section 8), or why there is none: "Can't read this USB stick" + (no or unsupported filesystem, mount failure, copy failure), "No updates on + this USB stick", or "USB updates are turned off". The helper unmounts the + stick before the daemon sees what it copied, so every outcome, on the + screen or as a banner, ends with "You can remove the USB stick" (the + notice's `footer`), even while the board is still installing. +- `usb_import = false` in `agent.toml` disables it (the helper says so on the + board and exits). - The helper's sandbox (`ProtectKernelModules=yes`) cannot load filesystem drivers, so `exfat` and `ntfs3` are loaded at boot from `/etc/modules-load.d/musallahboard.conf`. NTFS always mounts with `ntfs3`. @@ -529,6 +602,11 @@ folder on it, and plug it into the board. `sudo musallahboard-agent import ... | -` copies into the inbox (`-` reads one package from stdin) and waits for and prints the results. +`sudo musallahboard-agent update now` asks the daemon to check the release +channels and install a new app or agent at once (section 9.4), and prints the +results. `musallahboard-agent status` shows what is waiting and when it +installs. + ## 10. Clock Correct time decides which day's content is shown and the prayer times. @@ -546,6 +624,15 @@ Correct time decides which day's content is shown and the prayer times. sending; the board measures the offset when the request arrives) is applied only after at least one package in that request verified, only when it differs by more than 5 s, and only within `[clockFloor, clockFloor + 90 days]`. The response reports the drift. +- **Trust.** The floor keeps the clock from going backwards; it cannot say + the clock is right. `clock` in `/api/local/status` does: `source` is `ntp` + (systemd-timesyncd has synchronised it), `rtc` (a hardware clock is + fitted), `uploader` (an upload with a verified package set or confirmed it + since this boot, recorded against the kernel boot id), `starting` (the + first 3 minutes after boot) or `unverified`. `trusted` is false only for + `unverified`, and then the board shows "The clock may be wrong: connect the + board to the internet, or send an update from a laptop", the upload page + says the clock is not confirmed, and `status` suggests an RTC. - A DS3231 RTC is still recommended (`setup.sh --rtc`), and online boards keep `systemd-timesyncd`. @@ -562,6 +649,7 @@ unchanged. New keys, all optional: | `content_days` | `7` | Days per synced content package (1-31). | | `auto_update` | `true` | Follow the release channels. | | `app_channel_url`, `agent_channel_url` | section 9.4 | Channel pointers. Empty disables that channel. | +| `update_time` | `"23:00"` | When software from the channels installs, `HH:MM` in the board's time zone (section 9.4). | An offline board needs no configuration change: sync attempts fail fast with no route and cost nothing, and the moment it gets a network it starts syncing. @@ -585,9 +673,27 @@ route and cost nothing, and the moment it gets a network it starts syncing. must report the new `agentVersion`. Otherwise the updater restores `agent.prev`, restarts the service, and adds the version to `agentVersionsRejected`, so a broken release cannot loop. -5. The outcome is written to `agent/last-update.json`. The new daemon, finding - the kiosk on the update screen at startup, completes it and returns to the - board. +5. The outcome is written to `agent/last-update.json`, and only then, after a + rollback, is the previous agent restarted, so it can read it. + +Telling the people at the board (`internal/agentupdate`): the agent that +staged the package leaves the screen on "Restarting MusallahBoard" and its +batch's notice in `agent/pending-notice.json`. Then: + +- **success**: the new agent shows that notice ("Update complete · Agent + 0.3.0 · Board app 2.1.0") at startup; +- **rollback**: the previous agent, restarted, finds an unseen `rolled-back` + outcome and shows "Update not installed · Agent 0.3.0 did not start + correctly, so the board went back to 0.2.1", with the batch's other lines; +- **refusal**: the updater never restarts anything, so the staging agent + watches `last-update.json` for up to 3 minutes and shows "Agent 0.3.0 could + not be installed on this board" itself, then resumes its inbox. + +Each outcome is shown once (`agentUpdateSeen` in `state.json`), on the update +screen if the kiosk is still on it, else as a banner. If the batch had more +behind the agent (queued in the inbox), the screen is held with the agent's +outcome until that finishes, so the whole batch is one screen. The last +outcome is also `lastAgentUpdate` in `/api/local/status`. ## 13. Service port (eth0) @@ -604,10 +710,21 @@ connecting a board to a network permanently. - `kiosk-url` is `http://127.0.0.1:8080/` once enrolled (no query string; the page asks the agent who it is). Before enrollment it is absent and the - existing `waiting.html` splash shows. + `waiting.html` splash shows. The splash is embedded in the agent + (`packaging/waiting.html`) and written by `musallahboard-agent splash + install`, which setup.sh runs and the self-updater re-runs after each + update; the agent pushes the address and its own version into it over CDP. - `musallahboard-kiosk.service` is ordered `After=` and `Wants=` `musallahboard-agent.service`, and the agent reports `READY=1` only after the local server is listening, so Chromium never starts before the server. +- On an enrolled board the kiosk opens `starting.html` (a local page, embedded + in the agent with `waiting.html`) with the board's URL in its fragment. It + loads `/_mb/alive.gif` from the agent every 2 s and moves to the board once + that answers, so Chromium never shows its own "refused to connect" page + while the agent starts or restarts. +- The agent's unit never gives up restarting (`StartLimitIntervalSec=0`); a + crash loop backs off from 5 s to 2 minutes (`RestartSteps`, + `RestartMaxDelaySec`, systemd 254 and later). - The daemon still watches for Chromium's error page and navigates back to the board, as a backstop. @@ -625,6 +742,10 @@ per-day payloads from installed content, and weather comes through the agent. - Stale content: the "Content last updated ..." note when `staleDays > 0`. - Diagnostics (Alt+Shift+F) show agent and app versions, content range and source, days remaining, sync status. +- Waiting software updates (`updates` in the status) add "Update available: + ... will be installed at 11:00 PM tonight" to the ticker. +- The page fades in from black when it loads, finishing the dip to black the + agent starts when it leaves the update screen (section 8). - Development: `npm run dev` proxies `/api` and `/media` to an agent, for example a real board through `ssh -L 8080:127.0.0.1:8080 `. - Releases: `npm run package` builds and writes diff --git a/internal/agentupdate/agentupdate.go b/internal/agentupdate/agentupdate.go new file mode 100644 index 0000000..ccfeeda --- /dev/null +++ b/internal/agentupdate/agentupdate.go @@ -0,0 +1,234 @@ +// Package agentupdate tells the people at a board how an agent self-update +// went (docs/architecture.md, section 12). +// +// An agent update spans two processes. The running agent stages the package, +// puts "Restarting MusallahBoard" on the update screen and leaves its batch's +// outcome in agent/pending-notice.json. The root updater then either refuses +// the package (this agent keeps running), or swaps the binary and starts the +// new agent, rolling back to this one if the new one does not come up. The +// updater records which in agent/last-update.json before it starts anything. +// +// So the outcome is shown by whichever agent runs afterwards: +// +// - the new agent, at startup: "Update complete · Agent 0.3.0"; +// - this agent again, after a rollback, at startup: "Update not installed: +// agent 0.3.0 did not start correctly, so the board went back to 0.2.1"; +// - this agent, still running, when the updater refused: Watch notices. +// +// Each outcome is shown once (state.json's agentUpdateSeen). When the batch +// had more behind the agent (queued in the inbox for the new agent), the +// screen is held until that finishes, so there is one screen, not two. +package agentupdate + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/notice" + "github.com/LensBridge/agent/internal/selfupdate" + "github.com/LensBridge/agent/internal/state" + "github.com/LensBridge/agent/internal/store" +) + +// Screen is the update screen (package updatescreen). +type Screen interface { + OnScreen(ctx context.Context) bool + Finish(ctx context.Context, n notice.Notice) + Hold(ctx context.Context, n notice.Notice) +} + +// Deps is what a Reporter needs. +type Deps struct { + Layout store.Layout + Version string // this agent's + Screen Screen + // Banner shows a notice over the running board. + Banner func(notice.Notice) + // Resume lets the inbox take work again after a refused update. + Resume func() + Logger *slog.Logger + Now func() time.Time +} + +// Reporter shows agent update outcomes. +type Reporter struct { + d Deps + // watchTimeout bounds how long Watch waits for the root updater. + watchTimeout, watchPoll time.Duration +} + +// New returns a Reporter. +func New(d Deps) *Reporter { + if d.Logger == nil { + d.Logger = slog.New(slog.DiscardHandler) + } + if d.Now == nil { + d.Now = time.Now + } + return &Reporter{d: d, watchTimeout: 3 * time.Minute, watchPoll: 2 * time.Second} +} + +// LastOutcome is the root updater's last outcome, if any. +func (r *Reporter) LastOutcome() *selfupdate.Outcome { return selfupdate.ReadOutcome(r.d.Layout) } + +// Staged is called after a batch staged an agent (importer.Deps.AgentStaged): +// it leaves the batch's outcome for the next agent, and watches for the +// updater refusing the package, in which case nobody else would say so. +func (r *Reporter) Staged(n notice.Notice) { + raw, _ := json.Marshal(n) + if err := fsutil.WriteAtomic(r.d.Layout.AgentPendingNotice(), raw, 0o640); err != nil { + r.d.Logger.Warn("could not keep the update outcome for the new agent", "err", err) + } + go r.watch(context.Background(), r.d.Now()) +} + +// watch waits for the updater's verdict on a package staged at since. A new +// agent or a rollback restarts this process, so the only verdict this agent +// lives to see is a refusal; if none comes at all, the update did not run. +func (r *Reporter) watch(ctx context.Context, since time.Time) { + deadline := time.Now().Add(r.watchTimeout) + for time.Now().Before(deadline) { + if o := r.LastOutcome(); o != nil && o.Status == selfupdate.StatusRefused && !before(o.At, since) { + r.markSeen(o.At) + r.conclude(ctx, r.failure(*o, r.takePending())) + r.d.Resume() + return + } + time.Sleep(r.watchPoll) + } + r.d.Logger.Error("the agent update did not run: no outcome from the root updater", + "path", r.d.Layout.AgentLastUpdate()) + pending := r.takePending() + n := notice.New(notice.Problem, "Update not installed", + append([]string{"The agent update did not start"}, withoutAgent(pending, "")...)...) + r.conclude(ctx, n) + r.d.Resume() +} + +// Startup shows the outcome of an agent update that ended in this process +// starting: the new agent after a success, or this one after a rollback. Run +// it before the inbox, which may hold the rest of the batch. +func (r *Reporter) Startup(ctx context.Context) { + o := r.LastOutcome() + st, _ := r.d.Layout.State().Load() + unseen := o != nil && o.At != st.AgentUpdateSeen + if unseen { + r.markSeen(o.At) + } + pending := r.takePending() + onScreen := r.d.Screen.OnScreen(ctx) + + var n notice.Notice + switch { + case unseen && o.Status != selfupdate.StatusOK: + n = r.failure(*o, pending) + case pending != nil: + n = *pending + case onScreen: + // The screen was up with no record of why (an agent that crashed + // mid-update, say). Close it honestly. + n = notice.New(notice.OK, "Update complete", "Agent "+r.d.Version) + default: + return + } + r.d.Logger.Info("agent update outcome", "tone", n.Tone, "headline", n.Headline, "lines", n.Lines) + if !onScreen { + r.d.Banner(n) + return + } + r.conclude(ctx, n) +} + +// conclude finishes the screen with n, or holds it with n while the batch +// queued behind the agent installs. +func (r *Reporter) conclude(ctx context.Context, n notice.Notice) { + if r.queued() { + r.d.Screen.Hold(ctx, n) + return + } + r.d.Screen.Finish(ctx, n) +} + +// failure is the notice for an update that did not replace the agent. The +// lines from its batch that are not about the agent still stand. +func (r *Reporter) failure(o selfupdate.Outcome, pending *notice.Notice) notice.Notice { + var line string + switch { + case o.Status == selfupdate.StatusRolledBack && o.To != "": + line = "Agent " + o.To + " did not start correctly, so the board went back to " + o.From + case o.To != "": + line = "Agent " + o.To + " could not be installed on this board" + default: + line = "The agent update could not be installed" + } + return notice.New(notice.Problem, "Update not installed", append([]string{line}, withoutAgent(pending, o.To)...)...) +} + +// withoutAgent is a pending notice's lines, less the one announcing the agent +// that did not, after all, install. +func withoutAgent(n *notice.Notice, version string) []string { + if n == nil { + return nil + } + var out []string + for _, l := range n.Lines { + if strings.HasPrefix(l, "Agent ") && (version == "" || l == "Agent "+version) { + continue + } + out = append(out, l) + } + return out +} + +func (r *Reporter) takePending() *notice.Notice { + path := r.d.Layout.AgentPendingNotice() + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + os.Remove(path) + var n notice.Notice + if json.Unmarshal(raw, &n) != nil || n.Headline == "" { + return nil + } + return &n +} + +func (r *Reporter) markSeen(at string) { + if _, err := r.d.Layout.State().Update(func(s *state.State) error { + s.AgentUpdateSeen = at + return nil + }); err != nil { + r.d.Logger.Warn("could not record the agent update as shown", "err", err) + } +} + +// queued reports whether packages wait in the inbox (behind an agent update). +func (r *Reporter) queued() bool { + entries, err := os.ReadDir(r.d.Layout.Inbox()) + if errors.Is(err, fs.ErrNotExist) { + return false + } + for _, e := range entries { + n := e.Name() + if e.Type().IsRegular() && !strings.HasPrefix(n, ".") && filepath.Ext(n) == ".mbu" { + return true + } + } + return false +} + +// before reports whether the RFC 3339 time at is earlier than t, allowing a +// little for the updater's clock reading being taken in another process. +func before(at string, t time.Time) bool { + parsed, err := time.Parse(time.RFC3339, at) + return err != nil || parsed.Before(t.Add(-5*time.Second)) +} diff --git a/internal/agentupdate/agentupdate_test.go b/internal/agentupdate/agentupdate_test.go new file mode 100644 index 0000000..09bb46c --- /dev/null +++ b/internal/agentupdate/agentupdate_test.go @@ -0,0 +1,161 @@ +package agentupdate + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/LensBridge/agent/internal/notice" + "github.com/LensBridge/agent/internal/selfupdate" + "github.com/LensBridge/agent/internal/store" +) + +type fakeScreen struct { + onScreen bool + finished []notice.Notice + held []notice.Notice +} + +func (f *fakeScreen) OnScreen(context.Context) bool { return f.onScreen } +func (f *fakeScreen) Finish(_ context.Context, n notice.Notice) { + f.finished = append(f.finished, n) +} +func (f *fakeScreen) Hold(_ context.Context, n notice.Notice) { f.held = append(f.held, n) } + +type env struct { + l store.Layout + screen *fakeScreen + banners []notice.Notice + resumed int + r *Reporter +} + +func newEnv(t *testing.T, version string) *env { + t.Helper() + e := &env{l: store.Layout{Root: t.TempDir()}, screen: &fakeScreen{}} + os.MkdirAll(filepath.Join(e.l.Root, "agent"), 0o750) + e.r = New(Deps{ + Layout: e.l, Version: version, Screen: e.screen, + Banner: func(n notice.Notice) { e.banners = append(e.banners, n) }, + Resume: func() { e.resumed++ }, + }) + e.r.watchTimeout, e.r.watchPoll = 2*time.Second, 10*time.Millisecond + return e +} + +func (e *env) outcome(t *testing.T, o selfupdate.Outcome) { + t.Helper() + raw, _ := json.Marshal(o) + if err := os.WriteFile(e.l.AgentLastUpdate(), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +// staged is what the agent that staged the update left behind. +func staged(t *testing.T, e *env) { + t.Helper() + raw, _ := json.Marshal(notice.New(notice.OK, "Update complete", "Agent 0.3.0", "Board app 2.1.0")) + if err := os.WriteFile(e.l.AgentPendingNotice(), raw, 0o640); err != nil { + t.Fatal(err) + } +} + +func TestNewAgentShowsTheBatch(t *testing.T) { + e := newEnv(t, "0.3.0") + e.screen.onScreen = true + staged(t, e) + e.r.Startup(context.Background()) + if len(e.screen.finished) != 1 { + t.Fatalf("finished = %+v", e.screen.finished) + } + n := e.screen.finished[0] + if n.Tone != notice.OK || strings.Join(n.Lines, "|") != "Agent 0.3.0|Board app 2.1.0" { + t.Fatalf("outcome = %+v", n) + } + if _, err := os.Stat(e.l.AgentPendingNotice()); err == nil { + t.Fatal("the pending notice was kept; it would show again") + } +} + +func TestRollbackIsShownOnce(t *testing.T) { + e := newEnv(t, "0.2.1") + e.screen.onScreen = true + staged(t, e) + e.outcome(t, selfupdate.Outcome{From: "0.2.1", To: "0.3.0", At: "2026-09-25T23:01:00Z", + Status: selfupdate.StatusRolledBack, Message: "agent 0.3.0 did not come up healthy"}) + e.r.Startup(context.Background()) + if len(e.screen.finished) != 1 { + t.Fatalf("finished = %+v", e.screen.finished) + } + n := e.screen.finished[0] + want := "Agent 0.3.0 did not start correctly, so the board went back to 0.2.1|Board app 2.1.0" + if n.Tone != notice.Problem || n.Headline != "Update not installed" || strings.Join(n.Lines, "|") != want { + t.Fatalf("outcome = %+v", n) + } + + // The next start has nothing to say. + e.screen.onScreen = false + e.r.Startup(context.Background()) + if len(e.screen.finished) != 1 || len(e.banners) != 0 { + t.Fatalf("shown again: %+v %+v", e.screen.finished, e.banners) + } +} + +func TestRollbackOffScreenIsABanner(t *testing.T) { + e := newEnv(t, "0.2.1") + e.outcome(t, selfupdate.Outcome{From: "0.2.1", To: "0.3.0", At: "2026-09-25T23:01:00Z", Status: selfupdate.StatusRolledBack}) + e.r.Startup(context.Background()) + if len(e.banners) != 1 || e.banners[0].Tone != notice.Problem || len(e.screen.finished) != 0 { + t.Fatalf("banners %+v, finished %+v", e.banners, e.screen.finished) + } +} + +func TestQueuedBatchHoldsTheScreen(t *testing.T) { + e := newEnv(t, "0.3.0") + e.screen.onScreen = true + staged(t, e) + os.MkdirAll(e.l.Inbox(), 0o770) + os.WriteFile(filepath.Join(e.l.Inbox(), "usb-sda1-2-content.mbu"), []byte("x"), 0o640) + e.r.Startup(context.Background()) + if len(e.screen.held) != 1 || len(e.screen.finished) != 0 { + t.Fatalf("held %+v, finished %+v", e.screen.held, e.screen.finished) + } +} + +func TestPlainStartupSaysNothing(t *testing.T) { + e := newEnv(t, "0.3.0") + e.outcome(t, selfupdate.Outcome{From: "0.2.1", To: "0.3.0", At: "2026-09-25T23:01:00Z", Status: selfupdate.StatusOK}) + e.r.Startup(context.Background()) + if len(e.banners)+len(e.screen.finished)+len(e.screen.held) != 0 { + t.Fatal("a successful update from long ago was announced") + } +} + +func TestRefusalIsNoticed(t *testing.T) { + e := newEnv(t, "0.2.1") + e.screen.onScreen = true + e.r.d.Now = func() time.Time { return time.Date(2026, 9, 25, 23, 0, 0, 0, time.UTC) } + resumed := make(chan struct{}) + e.r.d.Resume = func() { close(resumed) } + // The outcome is in place before the watch starts, so the watch's reads + // and this write do not overlap. + e.outcome(t, selfupdate.Outcome{From: "0.2.1", To: "0.3.0", At: "2026-09-25T23:00:03Z", + Status: selfupdate.StatusRefused, Message: "the staged package did not verify"}) + e.r.Staged(notice.New(notice.OK, "Update complete", "Agent 0.3.0", "Board app 2.1.0")) + select { + case <-resumed: + case <-time.After(time.Second): + t.Fatal("the inbox was not resumed") + } + if len(e.screen.finished) != 1 { + t.Fatalf("finished %+v", e.screen.finished) + } + n := e.screen.finished[0] + if n.Tone != notice.Problem || strings.Join(n.Lines, "|") != "Agent 0.3.0 could not be installed on this board|Board app 2.1.0" { + t.Fatalf("outcome = %+v", n) + } +} diff --git a/internal/boardsync/boardsync.go b/internal/boardsync/boardsync.go index fad35a0..d36faf8 100644 --- a/internal/boardsync/boardsync.go +++ b/internal/boardsync/boardsync.go @@ -27,6 +27,7 @@ import ( "github.com/LensBridge/agent/internal/config" "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" "github.com/LensBridge/agent/internal/store" ) @@ -58,16 +59,30 @@ const ( // Deps is what a Syncer needs. type Deps struct { - Cfg *config.Config - Key ed25519.PrivateKey - Layout store.Layout - Importer *importer.Importer + Cfg *config.Config + Key ed25519.PrivateKey + Layout store.Layout + Importer *importer.Importer + // Updates holds software from the release channels until it installs. + Updates Updates AgentVersion string Logger *slog.Logger // HTTP is optional; New builds one with sane timeouts. HTTP *http.Client } +// Updates is the update scheduler (package updates) as the release channels +// see it. +type Updates interface { + // Pending is the version of kind already waiting to install, or "". + Pending(kind mbu.Type) string + // Offer verifies a downloaded package and keeps it until its install + // window. The file must be on the same filesystem as the store. + Offer(file string) error + // CheckDone records how a channel check went, for the status. + CheckDone(err error) +} + // Status is the "sync" object of /api/local/status. type Status struct { Enabled bool `json:"enabled"` diff --git a/internal/boardsync/boardsync_test.go b/internal/boardsync/boardsync_test.go index 56d36ef..632b1b4 100644 --- a/internal/boardsync/boardsync_test.go +++ b/internal/boardsync/boardsync_test.go @@ -30,6 +30,7 @@ import ( "github.com/LensBridge/agent/internal/state" "github.com/LensBridge/agent/internal/store" "github.com/LensBridge/agent/internal/trust" + "github.com/LensBridge/agent/internal/updates" ) const testDevice = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" @@ -45,9 +46,10 @@ var ( ) type env struct { - layout store.Layout - cfg *config.Config - syncer *Syncer + layout store.Layout + cfg *config.Config + syncer *Syncer + updates *updates.Scheduler } func strp(s string) *string { return &s } @@ -75,8 +77,9 @@ func newEnv(t *testing.T, backend string) *env { AgentVersion: "0.3.0", Ring: func() (*trust.Ring, error) { return ring, nil }, }) - s := New(Deps{Cfg: cfg, Key: deviceKey, Layout: l, Importer: im, AgentVersion: "0.3.0"}) - return &env{layout: l, cfg: cfg, syncer: s} + u := updates.New(updates.Deps{Layout: l, Importer: im, Hour: 23}) + s := New(Deps{Cfg: cfg, Key: deviceKey, Layout: l, Importer: im, Updates: u, AgentVersion: "0.3.0"}) + return &env{layout: l, cfg: cfg, syncer: s, updates: u} } func build(t *testing.T, m mbu.Manifest, src []mbu.Source, key ed25519.PrivateKey) []byte { @@ -293,9 +296,20 @@ func TestRefreshTriggersDebouncedSync(t *testing.T) { defer srv.Close() e := newEnv(t, srv.URL) e.syncer.triggerDelay = 50 * time.Millisecond - ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) - defer cancel() - e.syncer.Run(ctx) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { e.syncer.Run(ctx); close(done) }() + // Wait for the second sync rather than giving the whole exchange a fixed + // budget: under -race on a busy CI runner, start-up sync, WebSocket dial + // and debounce together can take longer than any small constant. + deadline := time.Now().Add(10 * time.Second) + for hits.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + // Then long enough for any extra, unfolded sync to show up. + time.Sleep(10 * e.syncer.triggerDelay) + cancel() + <-done mu.Lock() for _, c := range sockets { c.CloseNow() @@ -411,12 +425,12 @@ func TestAppChannel(t *testing.T) { installed string channel string downloads int32 - wantApp string + waiting string }{ - {"newer installs", "2.0.0", "2.1.0", 1, "2.1.0"}, - {"nothing installed installs", "", "2.1.0", 1, "2.1.0"}, - {"equal skips", "2.1.0", "2.1.0", 0, "2.1.0"}, - {"older skips", "2.2.0", "2.1.0", 0, "2.2.0"}, + {"newer waits", "2.0.0", "2.1.0", 1, "2.1.0"}, + {"nothing installed waits (and installs at once)", "", "2.1.0", 1, "2.1.0"}, + {"equal skips", "2.1.0", "2.1.0", 0, ""}, + {"older skips", "2.2.0", "2.1.0", 0, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -424,13 +438,25 @@ func TestAppChannel(t *testing.T) { e := newEnv(t, "http://unused") e.cfg.AppChannelSet = strp(cs.URL + "/channel.json") setAppVersion(t, e.layout, tc.installed) - e.syncer.CheckChannels(context.Background()) + if err := e.syncer.CheckChannels(context.Background()); err != nil { + t.Fatal(err) + } if n := cs.downloads.Load(); n != tc.downloads { t.Errorf("downloads = %d, want %d", n, tc.downloads) } + if got := e.updates.Pending(mbu.TypeApp); got != tc.waiting { + t.Errorf("waiting = %q, want %q", got, tc.waiting) + } st, _ := e.layout.State().Load() - if st.AppVersion != tc.wantApp { - t.Errorf("app version = %q, want %q", st.AppVersion, tc.wantApp) + if st.AppVersion != tc.installed { + t.Errorf("app version = %q: the channel check installed it", st.AppVersion) + } + // A second check does not download what is already waiting. + if err := e.syncer.CheckChannels(context.Background()); err != nil { + t.Fatal(err) + } + if n := cs.downloads.Load(); n != tc.downloads { + t.Errorf("downloads after a second check = %d, want %d", n, tc.downloads) } }) } @@ -476,13 +502,13 @@ func TestAgentChannel(t *testing.T) { channel string rejected []string downloads int32 - staged bool + waiting string }{ - {"newer stages", "0.3.0", "0.4.0", nil, 1, true}, - {"equal skips", "0.3.0", "0.3.0", nil, 0, false}, - {"older skips", "0.3.0", "0.2.9", nil, 0, false}, - {"rejected skips", "0.3.0", "0.4.0", []string{"0.4.0"}, 0, false}, - {"dev build skips", "dev", "0.4.0", nil, 0, false}, + {"newer waits", "0.3.0", "0.4.0", nil, 1, "0.4.0"}, + {"equal skips", "0.3.0", "0.3.0", nil, 0, ""}, + {"older skips", "0.3.0", "0.2.9", nil, 0, ""}, + {"rejected skips", "0.3.0", "0.4.0", []string{"0.4.0"}, 0, ""}, + {"dev build skips", "dev", "0.4.0", nil, 0, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -497,9 +523,11 @@ func TestAgentChannel(t *testing.T) { if n := cs.downloads.Load(); n != tc.downloads { t.Errorf("downloads = %d, want %d", n, tc.downloads) } - _, err := os.Stat(e.layout.StagedReady()) - if staged := err == nil; staged != tc.staged { - t.Errorf("staged = %v, want %v", staged, tc.staged) + if got := e.updates.Pending(mbu.TypeAgent); got != tc.waiting { + t.Errorf("waiting = %q, want %q", got, tc.waiting) + } + if _, err := os.Stat(e.layout.StagedReady()); err == nil { + t.Error("the channel check staged the agent instead of leaving it waiting") } }) } diff --git a/internal/boardsync/channel.go b/internal/boardsync/channel.go index 07aa25c..6d6f7f1 100644 --- a/internal/boardsync/channel.go +++ b/internal/boardsync/channel.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -14,7 +15,6 @@ import ( "runtime" "strings" - "github.com/LensBridge/agent/internal/importer" "github.com/LensBridge/agent/internal/mbu" ) @@ -36,33 +36,43 @@ func (s *Syncer) channelLoop(ctx context.Context) { return } for { - s.CheckChannels(ctx) + err := s.CheckChannels(ctx) + if ctx.Err() != nil { + return + } + s.d.Updates.CheckDone(err) + if err != nil { + s.log.Warn("release channel check failed", "err", err) + } if !sleep(ctx, s.channelInterval) { return } } } -// CheckChannels checks the app channel, then the agent channel. The app goes -// first because an app release may need a newer local API than the running -// agent serves; installing the agent first would restart the daemon and the -// app would wait six hours, while installing the app first either works or -// is refused with "update the agent first" and retried on the next round. -func (s *Syncer) CheckChannels(ctx context.Context) { - if u := s.d.Cfg.AppChannelURL(); u != "" { - if err := s.checkChannel(ctx, mbu.TypeApp, u); err != nil && ctx.Err() == nil { - s.log.Warn("app release channel check failed", "url", u, "err", err) +// CheckChannels checks the agent channel, then the app channel, and hands +// anything newer to the update scheduler, which installs it in the board's +// quiet window. The agent goes first because an app release may need a newer +// local API than the running agent serves: the scheduler only holds such an +// app when a new enough agent is waiting with it. +func (s *Syncer) CheckChannels(ctx context.Context) error { + var errs []error + if u := s.d.Cfg.AgentChannelURL(runtime.GOARCH); u != "" { + if err := s.checkChannel(ctx, mbu.TypeAgent, u); err != nil { + errs = append(errs, fmt.Errorf("agent channel: %w", err)) } } - if u := s.d.Cfg.AgentChannelURL(runtime.GOARCH); u != "" { - if err := s.checkChannel(ctx, mbu.TypeAgent, u); err != nil && ctx.Err() == nil { - s.log.Warn("agent release channel check failed", "url", u, "err", err) + if u := s.d.Cfg.AppChannelURL(); u != "" { + if err := s.checkChannel(ctx, mbu.TypeApp, u); err != nil { + errs = append(errs, fmt.Errorf("app channel: %w", err)) } } + return errors.Join(errs...) } // checkChannel fetches one channel and, if it points at something newer than -// what is installed, downloads and imports it. +// what is installed or already waiting, downloads it and offers it to the +// update scheduler. func (s *Syncer) checkChannel(ctx context.Context, kind mbu.Type, channelURL string) error { ch, err := s.fetchChannel(ctx, channelURL) if err != nil { @@ -99,6 +109,10 @@ func (s *Syncer) checkChannel(ctx context.Context, kind mbu.Type, channelURL str s.log.Debug("release channel: up to date", "type", kind, "installed", installed, "channel", ch.Version) return nil } + if waiting := s.d.Updates.Pending(kind); waiting != "" && mbu.CompareVersions(ch.Version, waiting) <= 0 { + s.log.Debug("release channel: already waiting to install", "type", kind, "version", waiting) + return nil + } s.log.Info("release channel offers an update", "type", kind, "installed", installed, "version", ch.Version) pkgURL, err := resolve(channelURL, ch.URL) @@ -114,12 +128,7 @@ func (s *Syncer) checkChannel(ctx context.Context, kind mbu.Type, channelURL str if err := s.download(ctx, pkgURL, file, ch); err != nil { return err } - - b := s.d.Importer.Import(ctx, importer.SourceSync, []string{file}) - for _, r := range b.Results { - s.log.Info("release channel import", "type", kind, "version", ch.Version, "action", r.Action, "message", r.Message) - } - return nil + return s.d.Updates.Offer(file) } func (s *Syncer) fetchChannel(ctx context.Context, channelURL string) (*Channel, error) { diff --git a/internal/clock/clock.go b/internal/clock/clock.go index 9dd40f9..e1b0b1f 100644 --- a/internal/clock/clock.go +++ b/internal/clock/clock.go @@ -12,6 +12,10 @@ // package in that same upload verified and the time is within 90 days of the // floor, so an unauthenticated client cannot set the board to any date it // likes. +// +// The floor keeps the clock from going backwards; it cannot say the clock is +// right. Info does: a board without NTP, an RTC, or a laptop's time since it +// last booted is showing whatever time it came up with, and says so. package clock import ( @@ -20,9 +24,12 @@ import ( "log/slog" "os" "os/exec" + "strconv" + "strings" "time" "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/state" "github.com/LensBridge/agent/internal/store" ) @@ -41,8 +48,34 @@ const ( // DefaultRTCDevice is the kernel's first hardware clock. DefaultRTCDevice = "/dev/rtc0" + + // BootGrace is how long after boot an unverified clock is given before + // the board says it may be wrong: an online board's NTP sync takes a + // moment, and a warning that flashes up at every boot is noise. + BootGrace = 3 * time.Minute +) + +// Where the clock's time comes from. +const ( + SourceNTP = "ntp" // systemd-timesyncd has synchronised it + SourceRTC = "rtc" // a battery-backed hardware clock + SourceUploader = "uploader" // a laptop or phone's time, since boot + // SourceStarting: just booted, not verified yet, not yet worth a + // warning (BootGrace). + SourceStarting = "starting" + // SourceUnverified: nothing has confirmed the time since boot. After a + // power cut on an offline board without an RTC it can be days out. + SourceUnverified = "unverified" ) +// Info says whether the board's clock can be believed. +type Info struct { + Source string `json:"source"` + // Trusted is false only when there is reason to warn: the board + // should say its clock may be wrong. + Trusted bool `json:"trusted"` +} + // ClockReport is what the upload server returns about the uploader's clock. // DriftSeconds is the board's clock minus the uploader's, measured before any // correction: positive means the board was ahead. @@ -63,6 +96,10 @@ type Keeper struct { WriteRTC func() error // RTCDevice is checked for existence by RTCPresent. RTCDevice string + // NTPSynced, Uptime and BootID are replaceable for tests. + NTPSynced func() bool + Uptime func() time.Duration + BootID func() string } // New returns a Keeper for the board's state in layout. @@ -77,9 +114,73 @@ func New(layout store.Layout, logger *slog.Logger) *Keeper { SetClock: setSystemClock, WriteRTC: writeRTC, RTCDevice: DefaultRTCDevice, + NTPSynced: timesyncdSynced, + Uptime: uptime, + BootID: bootID, } } +// Info reports where the clock's time comes from and whether to trust it. +func (k *Keeper) Info() Info { + switch { + case k.NTPSynced(): + return Info{Source: SourceNTP, Trusted: true} + case k.RTCPresent(): + return Info{Source: SourceRTC, Trusted: true} + } + if st, err := k.layout.State().Load(); err == nil && st.ClockSetBoot != "" && st.ClockSetBoot == k.BootID() { + return Info{Source: SourceUploader, Trusted: true} + } + if k.Uptime() < BootGrace { + return Info{Source: SourceStarting, Trusted: true} + } + return Info{Source: SourceUnverified, Trusted: false} +} + +// verifiedThisBoot records that an uploader's time confirmed the clock since +// this boot. +func (k *Keeper) verifiedThisBoot() { + id := k.BootID() + if id == "" { + return + } + if _, err := k.layout.State().Update(func(s *state.State) error { s.ClockSetBoot = id; return nil }); err != nil { + k.logger.Warn("clock: could not record the verified time", "err", err) + } +} + +// timesyncdSynced reports whether systemd-timesyncd has synchronised the +// clock since boot; it creates this file when it does (systemd 239 and +// later). +func timesyncdSynced() bool { + _, err := os.Stat("/run/systemd/timesync/synchronized") + return err == nil +} + +func uptime() time.Duration { + raw, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + f := strings.Fields(string(raw)) + if len(f) == 0 { + return 0 + } + secs, err := strconv.ParseFloat(f[0], 64) + if err != nil { + return 0 + } + return time.Duration(secs * float64(time.Second)) +} + +func bootID() string { + raw, err := os.ReadFile("/proc/sys/kernel/random/boot_id") + if err != nil { + return "" + } + return strings.TrimSpace(string(raw)) +} + // RTCPresent reports whether the board has a hardware clock. func (k *Keeper) RTCPresent() bool { _, err := os.Stat(k.RTCDevice) @@ -151,6 +252,9 @@ func (k *Keeper) ApplyClientTime(clientUnix int64, b importer.Batch) ClockReport rep := ClockReport{DriftSeconds: drift} if abs(drift) <= int64(ClientTolerance/time.Second) { rep.Note = "the board's clock agrees with this device" + if b.Verified { + k.verifiedThisBoot() + } return rep } if !b.Verified { @@ -178,6 +282,7 @@ func (k *Keeper) ApplyClientTime(clientUnix int64, b importer.Batch) ClockReport return rep } rep.Adjusted = true + k.verifiedThisBoot() rep.Note = fmt.Sprintf("the board's clock was %s and has been set to this device's time", describeDrift(drift)) k.logger.Info("clock set from uploader", "driftSeconds", drift, "to", target.UTC().Format(time.RFC3339)) if err := k.layout.State().RaiseClockFloor(clientUnix); err != nil { diff --git a/internal/clock/clock_test.go b/internal/clock/clock_test.go index aaf4989..12a4807 100644 --- a/internal/clock/clock_test.go +++ b/internal/clock/clock_test.go @@ -39,6 +39,9 @@ func newKeeper(t *testing.T, floor int64, now time.Time, withRTC bool) (*Keeper, return nil } k.WriteRTC = func() error { f.rtc++; return nil } + k.NTPSynced = func() bool { return false } + k.Uptime = func() time.Duration { return time.Hour } + k.BootID = func() string { return "boot-1" } k.RTCDevice = filepath.Join(t.TempDir(), "rtc0") if withRTC { os.WriteFile(k.RTCDevice, nil, 0o644) @@ -145,3 +148,41 @@ func TestApplyClientTimeSetFails(t *testing.T) { t.Fatal("reported adjusted after a failed set") } } + +func TestInfo(t *testing.T) { + k, _ := newKeeper(t, base.Unix(), base, false) + if i := k.Info(); i.Source != SourceUnverified || i.Trusted { + t.Fatalf("no NTP, RTC or uploader: %+v", i) + } + + k.Uptime = func() time.Duration { return time.Minute } + if i := k.Info(); i.Source != SourceStarting || !i.Trusted { + t.Fatalf("just booted: %+v", i) + } + k.Uptime = func() time.Duration { return time.Hour } + + // A laptop's time counts, until the next boot. + k.ApplyClientTime(base.Unix()+2, importer.Batch{Verified: true}) + if i := k.Info(); i.Source != SourceUploader || !i.Trusted { + t.Fatalf("after a verified upload: %+v", i) + } + k.BootID = func() string { return "boot-2" } + if i := k.Info(); i.Trusted { + t.Fatalf("an uploader's time survived a reboot: %+v", i) + } + + // An unverified upload proves nothing. + k.ApplyClientTime(base.Unix()+2, importer.Batch{}) + if i := k.Info(); i.Trusted { + t.Fatalf("after an unverified upload: %+v", i) + } + + os.WriteFile(k.RTCDevice, nil, 0o644) + if i := k.Info(); i.Source != SourceRTC || !i.Trusted { + t.Fatalf("with an RTC: %+v", i) + } + k.NTPSynced = func() bool { return true } + if i := k.Info(); i.Source != SourceNTP { + t.Fatalf("with NTP: %+v", i) + } +} diff --git a/internal/commands/update_install_now.go b/internal/commands/update_install_now.go new file mode 100644 index 0000000..6fb1349 --- /dev/null +++ b/internal/commands/update_install_now.go @@ -0,0 +1,28 @@ +package commands + +import ( + "context" + "encoding/json" + + "github.com/LensBridge/agent/internal/updates" +) + +// UpdateInstallNow checks the release channels and installs any new board app +// or agent at once, instead of in the board's quiet window. The board shows +// the update screen while it installs, as it would at night. +// +// The download counts against the command's deadline, so issue it with a +// generous one (the admin portal uses 10 minutes); an install that has started +// finishes regardless. An agent update restarts the agent a few seconds after +// this returns; the result is sent first, but may be lost if the restart wins +// the race. +type UpdateInstallNow struct { + Run func(ctx context.Context) updates.Outcome +} + +func (h *UpdateInstallNow) Kind() string { return "update.install_now" } + +func (h *UpdateInstallNow) Execute(ctx context.Context, _ json.RawMessage, progress ProgressFn) (any, error) { + progress("checking", "asking the release channels for new software", nil) + return h.Run(ctx), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index f594045..d188406 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,7 +8,8 @@ // key_path = "/etc/musallahboard/agent.key" // // Optional keys (docs/architecture.md, section 11): service_port, usb_import, -// content_sync, content_days, auto_update, app_channel_url, agent_channel_url. +// content_sync, content_days, auto_update, app_channel_url, agent_channel_url, +// update_time. // // Identity is proved by signing a server-issued challenge with the Ed25519 key // at KeyPath. @@ -18,6 +19,8 @@ import ( "fmt" "os" "path/filepath" + "strings" + "time" "github.com/BurntSushi/toml" ) @@ -30,6 +33,7 @@ const ( DefaultAppChannelURL = "https://github.com/LensBridge/MusallahBoard/releases/latest/download/app-channel.json" DefaultAgentChannelURL = "https://github.com/LensBridge/agent/releases/latest/download/agent-channel-%s.json" DefaultContentDays = 7 + DefaultUpdateHour = 23 ) // Config is the persisted agent configuration written at enrollment. @@ -51,6 +55,7 @@ type Config struct { AutoUpdateSet *bool `toml:"auto_update,omitempty"` AppChannelSet *string `toml:"app_channel_url,omitempty"` AgentChannelSet *string `toml:"agent_channel_url,omitempty"` + UpdateTimeSet *string `toml:"update_time,omitempty"` } func boolOr(p *bool, def bool) bool { @@ -98,6 +103,18 @@ func (c *Config) AgentChannelURL(arch string) string { return fmt.Sprintf(DefaultAgentChannelURL, arch) } +// UpdateTime is when downloaded software installs, as hour and minute in the +// board's time zone (update_time, "HH:MM", default 23:00). A malformed value +// falls back to the default rather than stopping updates. +func (c *Config) UpdateTime() (hour, minute int) { + if c.UpdateTimeSet != nil { + if t, err := time.Parse("15:04", strings.TrimSpace(*c.UpdateTimeSet)); err == nil { + return t.Hour(), t.Minute() + } + } + return DefaultUpdateHour, 0 +} + func Load(path string) (*Config, error) { var c Config if _, err := toml.DecodeFile(path, &c); err != nil { diff --git a/internal/events/events.go b/internal/events/events.go index 715a69f..4bfbfeb 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -13,6 +13,8 @@ import ( "net/http" "sync" "time" + + "github.com/LensBridge/agent/internal/notice" ) // Event is one SSE message. @@ -37,6 +39,17 @@ func (h *Hub) AppChanged(version string) { h.Publish(Event{Name: "app", Data: map[string]string{"version": version}}) } +// Notice implements importer.Events: a banner for the running board. +func (h *Hub) Notice(n notice.Notice) { + h.Publish(Event{Name: "notice", Data: n}) +} + +// UpdatesChanged announces a change in the software waiting to install +// (updates.Info), so the ticker can say so without waiting for a poll. +func (h *Hub) UpdatesChanged(info any) { + h.Publish(Event{Name: "updates", Data: info}) +} + // Publish sends e to every subscriber that has room for it. func (h *Hub) Publish(e Event) { h.mu.Lock() diff --git a/internal/importer/importer.go b/internal/importer/importer.go index c985209..e9b3254 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -23,6 +23,7 @@ import ( "github.com/LensBridge/agent/internal/fsutil" "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/notice" "github.com/LensBridge/agent/internal/state" "github.com/LensBridge/agent/internal/store" "github.com/LensBridge/agent/internal/trust" @@ -45,11 +46,16 @@ const ( // Actions a package can end in. const ( ActionInstalled = "installed" - ActionUnchanged = "unchanged" ActionStaged = "staged" - ActionSkipped = "skipped" - ActionRejected = "rejected" - ActionQueued = "queued" + // ActionUnchanged: exactly this is already installed. + ActionUnchanged = "unchanged" + // ActionOutdated: the board already has something newer. Not a failure: + // an old USB stick at an online board is the normal case. + ActionOutdated = "outdated" + // ActionSkipped: content for another board. One stick serves many. + ActionSkipped = "skipped" + ActionRejected = "rejected" + ActionQueued = "queued" ) // Result is the outcome for one package. @@ -59,12 +65,19 @@ type Result struct { Version string `json:"version,omitempty"` Sequence int64 `json:"sequence,omitempty"` Action string `json:"action"` - Message string `json:"message"` + // Message is one plain sentence for whoever is at the board: it is what + // the update screen, the banner, the upload page and the CLI show. + Message string `json:"message"` + // Detail is the technical reason behind a rejection, for logs and for + // the people reading them. + Detail string `json:"detail,omitempty"` } // Batch is the outcome of one import. type Batch struct { Results []Result `json:"results"` + // Notice is what the board showed about the batch. + Notice notice.Notice `json:"notice"` // Verified reports whether at least one package authenticated. The // upload server only trusts an uploader's clock when it did. Verified bool `json:"-"` @@ -86,17 +99,25 @@ func (b Batch) OK() bool { // Screen is the kiosk's update screen (package updatescreen). type Screen interface { + // Active reports whether the screen is up, possibly held over from + // before an agent restart for this batch to finish. + Active() bool Begin(ctx context.Context) Caption(ctx context.Context, text string) // Finish shows the outcome and returns the kiosk to the board after a - // countdown. restarting means the agent is about to be replaced. - Finish(ctx context.Context, ok bool, detail string, restarting bool) + // countdown. + Finish(ctx context.Context, n notice.Notice) + // Restarting says the agent is about to be replaced. The new agent + // finishes the screen (package agentupdate). + Restarting(ctx context.Context) } -// Events is told about installs the page should react to. +// Events is told about installs the page should react to, and carries the +// banners shown over the running board. type Events interface { ContentChanged(sequence int64) AppChanged(version string) + Notice(n notice.Notice) } // Deps is what an Importer needs. @@ -109,14 +130,46 @@ type Deps struct { Ring func() (*trust.Ring, error) Screen Screen // may be nil Events Events // may be nil - Logger *slog.Logger - Now func() time.Time + // AgentStaged is called after a batch staged a new agent, with the + // batch's notice, which the new agent shows once it runs. May be nil. + AgentStaged func(n notice.Notice) + Logger *slog.Logger + Now func() time.Time } // Importer serialises imports. type Importer struct { d Deps mu sync.Mutex + + // staged is closed when a staged agent turns out not to replace this + // one (the root updater refused it), so the inbox takes work again. + stagedMu sync.Mutex + staged chan struct{} +} + +// AgentNotReplaced says the staged agent update ended without replacing this +// agent, so the inbox should carry on (package agentupdate). +func (im *Importer) AgentNotReplaced() { + im.stagedMu.Lock() + defer im.stagedMu.Unlock() + if im.staged != nil { + close(im.staged) + im.staged = nil + } +} + +// awaitReplacement returns a channel closed by AgentNotReplaced, or already +// closed when no agent is staged. +func (im *Importer) awaitReplacement() <-chan struct{} { + im.stagedMu.Lock() + defer im.stagedMu.Unlock() + if im.staged == nil { + c := make(chan struct{}) + close(c) + return c + } + return im.staged } // New returns an Importer. @@ -164,6 +217,17 @@ func rank(t mbu.Type) int { return 2 } +// progressLine says where a batch someone brought came from. +var progressLine = map[Source]string{ + SourceUSB: "From the USB stick", + SourceUpload: "From a laptop or phone", + SourceCLI: "From the command line", +} + +// run processes one batch. The update screen goes up only once something is +// about to be installed; until then, and when nothing is, a person at the +// board sees a banner over the running board instead. Background sync is +// silent for content and uses the screen for software. func (im *Importer) run(ctx context.Context, src Source, files []string) Batch { var b Batch items := make([]item, 0, len(files)) @@ -173,21 +237,17 @@ func (im *Importer) run(ctx context.Context, src Source, files []string) Batch { } sort.SliceStable(items, func(i, j int) bool { return rank(peekType(items[i])) < rank(peekType(items[j])) }) - // The screen goes up for anything a person brought to the board, unless - // everything in it is someone else's content. Background sync only puts - // it up for software. - screen := false - for _, it := range items { - if it.peek != nil && it.peek.Type == mbu.TypeContent && it.peek.DeviceID != "" && it.peek.DeviceID != im.d.DeviceID { - continue - } - if src != SourceSync || peekType(it) != mbu.TypeContent { - screen = true + interactive := src != SourceSync + banner := func(n notice.Notice) { + if im.d.Events != nil && interactive { + im.d.Events.Notice(n) } } - shown := false - show := func(caption string) { - if im.d.Screen == nil || !screen { + // A screen held over from before an agent restart belongs to this batch. + shown := im.d.Screen != nil && im.d.Screen.Active() + banner(notice.New(notice.Progress, "Checking updates", progressLine[src])) + show := func(kind mbu.Type, caption string) { + if im.d.Screen == nil || (!interactive && kind == mbu.TypeContent) { return } if !shown { @@ -198,40 +258,89 @@ func (im *Importer) run(ctx context.Context, src Source, files []string) Batch { } ring, ringErr := im.d.Ring() - for i, it := range items { + for _, it := range items { name := filepath.Base(it.path) if b.AgentStaged { b.Results = append(b.Results, im.requeue(it.path, name)) continue } if it.peek != nil && it.peek.Type == mbu.TypeContent && it.peek.DeviceID != "" && it.peek.DeviceID != im.d.DeviceID { - b.Results = append(b.Results, Result{File: name, Type: mbu.TypeContent, Action: ActionSkipped, - Message: "content for another board (" + shortID(it.peek.DeviceID) + "); left alone"}) + b.Results = append(b.Results, skippedForeign(name)) continue } - label := "update" - if it.peek != nil { - label = it.peek.Describe() - } - show(fmt.Sprintf("Checking %s (%d of %d)", label, i+1, len(items))) var r Result if ringErr != nil { - r = reject(name, fmt.Errorf("cannot read the trust store: %w", ringErr)) + r = Result{File: name, Action: ActionRejected, + Message: "The board cannot check updates: its list of trusted keys is unreadable", + Detail: ringErr.Error()} } else { r = im.one(ctx, src, it.path, name, ring, &b, show) } - im.d.Logger.Info("import", "source", src, "file", name, "type", r.Type, "action", r.Action, "message", r.Message) + im.d.Logger.Info("import", "source", src, "file", name, "type", r.Type, "action", r.Action, + "message", r.Message, "detail", r.Detail) b.Results = append(b.Results, r) } - if shown { - ok := b.OK() - detail := summary(b) - im.d.Screen.Finish(ctx, ok, detail, b.AgentStaged) + b.Notice = Summarize(b) + if src == SourceUSB { + b.Notice.Footer = notice.RemoveStick + } + if b.AgentStaged { + im.stagedMu.Lock() + im.staged = make(chan struct{}) + im.stagedMu.Unlock() + } + switch { + case shown && b.AgentStaged: + im.d.Screen.Restarting(ctx) + if im.d.AgentStaged != nil { + im.d.AgentStaged(b.Notice) + } + case shown: + im.d.Screen.Finish(ctx, b.Notice) + default: + banner(b.Notice) } return b } +// Summarize is what the board shows about a batch: what was installed first, +// then anything that went wrong; or, when nothing needed doing, why not. +func Summarize(b Batch) notice.Notice { + var installed, problems, neutral []string + foreign := 0 + for _, r := range b.Results { + switch r.Action { + case ActionInstalled, ActionStaged: + installed = append(installed, r.Message) + case ActionRejected: + problems = append(problems, r.Message) + case ActionSkipped: + foreign++ + case ActionUnchanged, ActionOutdated: + neutral = append(neutral, r.Message) + } + } + switch { + case len(installed) > 0 && len(problems) > 0: + return notice.New(notice.Problem, "Some updates were not installed", append(problems, installed...)...) + case len(installed) > 0: + return notice.New(notice.OK, "Update complete", installed...) + case len(problems) > 0: + return notice.New(notice.Problem, "Update not installed", problems...) + case len(neutral) > 0: + return notice.New(notice.Neutral, "Already up to date", neutral...) + case foreign > 0: + return notice.New(notice.Neutral, "Nothing here is for this board", + "These updates are for other MusallahBoards") + } + return notice.New(notice.Neutral, "No updates found") +} + +func skippedForeign(name string) Result { + return Result{File: name, Type: mbu.TypeContent, Action: ActionSkipped, Message: "Content for another board"} +} + func peekType(it item) mbu.Type { if it.peek == nil { return mbu.TypeContent @@ -239,11 +348,15 @@ func peekType(it item) mbu.Type { return it.peek.Type } -func (im *Importer) one(ctx context.Context, src Source, path, name string, ring *trust.Ring, b *Batch, show func(string)) Result { +func (im *Importer) one(ctx context.Context, src Source, path, name string, ring *trust.Ring, b *Batch, show func(mbu.Type, string)) Result { l := im.d.Layout pkg, err := mbu.Open(path, ring, mbu.OpenOptions{HaveMedia: l.HaveMedia}) if err != nil { - return reject(name, err) + msg := "A file is damaged or is not a MusallahBoard update" + if errors.Is(err, mbu.ErrNotAuthentic) { + msg = "An update is not signed for this board" + } + return Result{File: name, Action: ActionRejected, Message: msg, Detail: err.Error()} } defer pkg.Close() m := pkg.Manifest @@ -257,7 +370,7 @@ func (im *Importer) one(ctx context.Context, src Source, path, name string, ring } st, err := l.State().Load() if err != nil { - return reject(name, err) + return failed(Result{File: name, Type: m.Type}, "The board cannot read its own state", err) } res := Result{File: name, Type: m.Type, Version: m.Version, Sequence: m.Sequence} now := im.d.Now() @@ -265,20 +378,20 @@ func (im *Importer) one(ctx context.Context, src Source, path, name string, ring switch m.Type { case mbu.TypeContent: if m.DeviceID != im.d.DeviceID { - res.Action, res.Message = ActionSkipped, "content for another board ("+shortID(m.DeviceID)+"); left alone" - return res + return skippedForeign(name) } switch { case m.Sequence < st.ContentSequence: - return rejectAs(res, "this content is older than what is installed") + res.Action, res.Message = ActionOutdated, "The board already has newer content" + return res case m.Sequence == st.ContentSequence: - res.Action, res.Message = ActionUnchanged, "this content is already installed" + res.Action, res.Message = ActionUnchanged, "This content is already on the board" return res } - show("Installing " + m.Describe()) + show(m.Type, "Installing "+describe(m)) changed, err := l.InstallContent(pkg, string(src), now) if err != nil && !strings.Contains(err.Error(), "content installed, but") { - return rejectAs(res, err.Error()) + return failed(res, "Installing the new content failed", err) } if err != nil { im.d.Logger.Warn("content cleanup", "err", err) @@ -294,23 +407,20 @@ func (im *Importer) one(ctx context.Context, src Source, path, name string, ring if changed && im.d.Events != nil { im.d.Events.ContentChanged(m.Sequence) } - res.Action, res.Message = ActionInstalled, "Installed "+m.Describe() + res.Action, res.Message = ActionInstalled, installedLine(m) case mbu.TypeApp: if m.App.LocalAPI > LocalAPIVersion { - return rejectAs(res, fmt.Sprintf("board app %s needs a newer agent (local API %d; this agent serves %d): install the agent update first", - m.Version, m.App.LocalAPI, LocalAPIVersion)) + return failed(res, fmt.Sprintf("Board app %s needs a newer agent: install the agent update first", m.Version), + fmt.Errorf("app needs local API %d; this agent serves %d", m.App.LocalAPI, LocalAPIVersion)) } - switch c := mbu.CompareVersions(m.Version, st.AppVersion); { - case c < 0: - return rejectAs(res, "board app "+m.Version+" is older than the installed "+st.AppVersion) - case c == 0: - res.Action, res.Message = ActionUnchanged, "board app "+m.Version+" is already installed" + if action, msg := im.checkSoftware(m, st); action != "" { + res.Action, res.Message = action, msg return res } - show("Installing " + m.Describe()) + show(m.Type, "Installing "+describe(m)) if err := l.InstallApp(pkg, string(src), now); err != nil && !strings.Contains(err.Error(), "app installed, but") { - return rejectAs(res, err.Error()) + return failed(res, "Installing board app "+m.Version+" failed", err) } if _, err := l.State().Update(func(s *state.State) error { if mbu.CompareVersions(m.Version, s.AppVersion) > 0 { @@ -323,30 +433,106 @@ func (im *Importer) one(ctx context.Context, src Source, path, name string, ring if im.d.Events != nil { im.d.Events.AppChanged(m.Version) } - res.Action, res.Message = ActionInstalled, "Installed "+m.Describe() + res.Action, res.Message = ActionInstalled, installedLine(m) + + case mbu.TypeAgent: + if action, msg := im.checkSoftware(m, st); action != "" { + res.Action, res.Message = action, msg + return res + } + show(m.Type, "Installing "+describe(m)) + if err := im.stageAgent(path); err != nil { + return failed(res, "Preparing agent "+m.Version+" failed", err) + } + b.AgentStaged = true + res.Action, res.Message = ActionStaged, installedLine(m) + } + return res +} + +// describe names a package for the working screen's caption. +func describe(m *mbu.Manifest) string { + switch m.Type { + case mbu.TypeContent: + return "new content" + case mbu.TypeApp: + return "board app " + m.Version + case mbu.TypeAgent: + return "agent " + m.Version + } + return "update" +} + +// installedLine is the line an installed package gets on the outcome. +func installedLine(m *mbu.Manifest) string { + switch m.Type { + case mbu.TypeContent: + if t, err := time.Parse("2006-01-02", m.Content.LastDay); err == nil { + return "New content, through " + t.Format("Monday, January 2") + } + return "New content" + case mbu.TypeApp: + return "Board app " + m.Version + case mbu.TypeAgent: + return "Agent " + m.Version + } + return "Update" +} +// checkSoftware decides whether an app or agent package may replace what the +// board runs. It returns "" when it may, else the action (unchanged, outdated +// or rejected) and the line to show. The app's local API is checked at +// install time only: an app waiting for its agent is scheduled with it. +func (im *Importer) checkSoftware(m *mbu.Manifest, st state.State) (action, msg string) { + switch m.Type { + case mbu.TypeApp: + switch c := mbu.CompareVersions(m.Version, st.AppVersion); { + case c < 0: + return ActionOutdated, "The board already has a newer board app (" + st.AppVersion + ")" + case c == 0: + return ActionUnchanged, "Board app " + m.Version + " is already installed" + } case mbu.TypeAgent: if m.Agent.Arch != runtime.GOARCH { - return rejectAs(res, fmt.Sprintf("agent %s is built for %s; this board is %s", m.Version, m.Agent.Arch, runtime.GOARCH)) + return ActionRejected, fmt.Sprintf("Agent %s is for a different kind of board (%s, not %s)", m.Version, m.Agent.Arch, runtime.GOARCH) } if st.AgentRejected(m.Version) { - return rejectAs(res, "agent "+m.Version+" failed to start on this board before; it will not be retried") + return ActionRejected, "Agent " + m.Version + " did not start on this board before, so it is not tried again" } switch c := mbu.CompareVersions(m.Version, im.d.AgentVersion); { case c < 0: - return rejectAs(res, "agent "+m.Version+" is older than the running "+im.d.AgentVersion) + return ActionOutdated, "The board already has a newer agent (" + im.d.AgentVersion + ")" case c == 0: - res.Action, res.Message = ActionUnchanged, "agent "+m.Version+" is already running" - return res + return ActionUnchanged, "Agent " + m.Version + " is already running" } - show("Preparing " + m.Describe()) - if err := im.stageAgent(path); err != nil { - return rejectAs(res, err.Error()) - } - b.AgentStaged = true - res.Action, res.Message = ActionStaged, "Installing "+m.Describe()+"; the agent restarts in a moment" + default: + return ActionRejected, "This is not a software update" } - return res + return "", "" +} + +// Preflight verifies a downloaded app or agent package and checks that it +// would install, without installing anything or recording anything about it. +// The update scheduler only holds packages that pass, so what a board +// announces is what it will install. +func (im *Importer) Preflight(path string) (*mbu.Manifest, error) { + ring, err := im.d.Ring() + if err != nil { + return nil, fmt.Errorf("cannot read the trust store: %w", err) + } + pkg, err := mbu.Open(path, ring, mbu.OpenOptions{HaveMedia: im.d.Layout.HaveMedia}) + if err != nil { + return nil, err + } + defer pkg.Close() + st, err := im.d.Layout.State().Load() + if err != nil { + return nil, err + } + if action, msg := im.checkSoftware(pkg.Manifest, st); action != "" { + return nil, errors.New(msg) + } + return pkg.Manifest, nil } // stageAgent hands a verified agent package to the root updater. The updater @@ -381,47 +567,20 @@ func (im *Importer) requeue(path, name string) Result { dst := filepath.Join(inbox, fsutil.UniqueName(inbox, name)) if err := os.MkdirAll(inbox, 0o770); err == nil { if err := os.Rename(path, dst); err != nil { - return Result{File: name, Action: ActionRejected, Message: "could not queue it behind the agent update: " + err.Error()} + return Result{File: name, Action: ActionRejected, Message: "Could not keep an update for after the agent restarts", Detail: err.Error()} } } } - return Result{File: name, Action: ActionQueued, Message: "installs after the agent update restarts the agent"} + return Result{File: name, Action: ActionQueued, Message: "Installs once the new agent is running"} } -func reject(name string, err error) Result { - return Result{File: name, Action: ActionRejected, Message: err.Error()} -} - -func rejectAs(r Result, msg string) Result { +// failed rejects r with a plain message, keeping err for the logs. +func failed(r Result, msg string, err error) Result { r.Action, r.Message = ActionRejected, msg - return r -} - -func shortID(id string) string { - if len(id) > 8 { - return id[:8] - } - return id -} - -// summary is the one line the update screen shows under its outcome. -func summary(b Batch) string { - var installed, rejected []string - for _, r := range b.Results { - switch r.Action { - case ActionInstalled, ActionStaged: - installed = append(installed, r.Message) - case ActionRejected: - rejected = append(rejected, r.File+": "+r.Message) - } - } - if len(rejected) > 0 { - return rejected[0] - } - if len(installed) > 0 { - return strings.Join(installed, " · ") + if err != nil { + r.Detail = err.Error() } - return "Nothing new to install" + return r } // Busy reports whether an import is running right now. diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index 24e3a3a..d19dcea 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -6,6 +6,7 @@ import ( "crypto/ed25519" "crypto/sha256" "encoding/hex" + "encoding/json" "os" "path/filepath" "runtime" @@ -15,6 +16,7 @@ import ( "time" "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/notice" "github.com/LensBridge/agent/internal/store" "github.com/LensBridge/agent/internal/trust" ) @@ -35,16 +37,28 @@ func imgPath() string { type fakeEvents struct { content []int64 app []string + notices []notice.Notice } -func (f *fakeEvents) ContentChanged(s int64) { f.content = append(f.content, s) } -func (f *fakeEvents) AppChanged(v string) { f.app = append(f.app, v) } +func (f *fakeEvents) ContentChanged(s int64) { f.content = append(f.content, s) } +func (f *fakeEvents) AppChanged(v string) { f.app = append(f.app, v) } +func (f *fakeEvents) Notice(n notice.Notice) { f.notices = append(f.notices, n) } +func (f *fakeEvents) last() (n notice.Notice) { return f.notices[len(f.notices)-1] } -type fakeScreen struct{ begun, finished int } +type fakeScreen struct { + begun, restarting int + active bool + finished []notice.Notice +} -func (f *fakeScreen) Begin(context.Context) { f.begun++ } -func (f *fakeScreen) Caption(context.Context, string) {} -func (f *fakeScreen) Finish(context.Context, bool, string, bool) { f.finished++ } +func (f *fakeScreen) Active() bool { return f.active } +func (f *fakeScreen) Begin(context.Context) { f.begun++; f.active = true } +func (f *fakeScreen) Caption(context.Context, string) {} +func (f *fakeScreen) Restarting(context.Context) { f.restarting++ } +func (f *fakeScreen) Finish(_ context.Context, n notice.Notice) { + f.finished = append(f.finished, n) + f.active = false +} func setup(t *testing.T) (*Importer, store.Layout, *fakeEvents, *fakeScreen) { t.Helper() @@ -121,7 +135,7 @@ func TestContentLifecycle(t *testing.T) { if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 100, dev, poster, false)})); r.Action != ActionUnchanged { t.Fatalf("same sequence: %+v", r) } - if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 50, dev, poster, false)})); r.Action != ActionRejected { + if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 50, dev, poster, false)})); r.Action != ActionOutdated { t.Fatalf("older sequence: %+v", r) } // Delta: media omitted, satisfied from the store. @@ -134,7 +148,7 @@ func TestContentLifecycle(t *testing.T) { } // Rollback stays closed even if the bundles are deleted. os.RemoveAll(l.BundlesDir()) - if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 150, dev, poster, false)})); r.Action != ActionRejected { + if r := one(t, im.Import(ctx, SourceUSB, []string{content(t, 150, dev, poster, false)})); r.Action != ActionOutdated { t.Fatalf("rollback after deleting bundles: %+v", r) } } @@ -152,7 +166,7 @@ func TestPosterMustBeLocalMedia(t *testing.T) { im, _, _, _ := setup(t) r := one(t, im.Import(context.Background(), SourceUSB, []string{content(t, 100, dev, "https://evil.example/x.png", false)})) - if r.Action != ActionRejected || !strings.Contains(r.Message, "posterUrl") { + if r.Action != ActionRejected || !strings.Contains(r.Detail, "posterUrl") || strings.Contains(r.Message, "posterUrl") { t.Fatalf("remote posterUrl: %+v", r) } } @@ -169,7 +183,7 @@ func TestAppRules(t *testing.T) { if len(ev.app) != 1 { t.Fatalf("app events %v", ev.app) } - if r := one(t, im.Import(ctx, SourceUpload, []string{app(t, "2.0.9", 2)})); r.Action != ActionRejected { + if r := one(t, im.Import(ctx, SourceUpload, []string{app(t, "2.0.9", 2)})); r.Action != ActionOutdated { t.Fatalf("older app: %+v", r) } if r := one(t, im.Import(ctx, SourceUpload, []string{app(t, "3.0.0", LocalAPIVersion+1)})); r.Action != ActionRejected || @@ -186,11 +200,13 @@ func TestAppRules(t *testing.T) { func TestAgentStagingRequeuesTheRest(t *testing.T) { im, l, _, sc := setup(t) + var staged *notice.Notice + im.d.AgentStaged = func(n notice.Notice) { staged = &n } ctx := context.Background() if r := one(t, im.Import(ctx, SourceUpload, []string{agent(t, "0.3.0", otherArch())})); r.Action != ActionRejected { t.Fatalf("wrong arch: %+v", r) } - if r := one(t, im.Import(ctx, SourceUpload, []string{agent(t, "0.1.0", runtime.GOARCH)})); r.Action != ActionRejected { + if r := one(t, im.Import(ctx, SourceUpload, []string{agent(t, "0.1.0", runtime.GOARCH)})); r.Action != ActionOutdated { t.Fatalf("older agent: %+v", r) } // Content listed first must still wait for the agent: agents go first. @@ -204,8 +220,101 @@ func TestAgentStagingRequeuesTheRest(t *testing.T) { if files, _ := filepath.Glob(filepath.Join(l.Inbox(), "*.mbu")); len(files) != 1 { t.Fatalf("requeued files = %v", files) } - if sc.finished == 0 { - t.Fatal("screen not finished") + // The new agent finishes the screen; this one only says it restarts. + if sc.restarting != 1 || len(sc.finished) != 0 { + t.Fatalf("restarting %d, finished %+v", sc.restarting, sc.finished) + } + if staged == nil || staged.Tone != notice.OK || staged.Lines[0] != "Agent 0.3.0" { + t.Fatalf("notice handed to the next agent: %+v", staged) + } +} + +// Nothing to install: no update screen, a banner instead. +func TestNothingNewIsABanner(t *testing.T) { + im, _, ev, sc := setup(t) + ctx := context.Background() + poster := "/" + imgPath() + one(t, im.Import(ctx, SourceSync, []string{content(t, 200, dev, poster, false)})) + ev.notices = nil + + one(t, im.Import(ctx, SourceUSB, []string{content(t, 100, dev, poster, false)})) + if sc.begun != 0 { + t.Fatal("the update screen went up for nothing") + } + if len(ev.notices) != 2 || ev.notices[0].Tone != notice.Progress { + t.Fatalf("banners = %+v", ev.notices) + } + if n := ev.last(); n.Tone != notice.Neutral || n.Headline != "Already up to date" || + n.Lines[0] != "The board already has newer content" { + t.Fatalf("banner = %+v", n) + } + + // A package that does not verify: a problem banner, still no screen. + p := build(t, mbu.Manifest{Type: mbu.TypeApp, Version: "9.0.0", App: &mbu.AppInfo{LocalAPI: 2}}, + []mbu.Source{{Path: "index.html", Data: []byte("x")}}, contentKey) + one(t, im.Import(ctx, SourceUpload, []string{p})) + if n := ev.last(); sc.begun != 0 || n.Tone != notice.Problem || n.Headline != "Update not installed" || + n.Lines[0] != "An update is not signed for this board" { + t.Fatalf("banner = %+v, screen begun %d", n, sc.begun) + } +} + +func TestInstallFinishesTheScreenInPlainWords(t *testing.T) { + im, _, ev, sc := setup(t) + one(t, im.Import(context.Background(), SourceUSB, []string{content(t, 100, dev, "/"+imgPath(), false)})) + if sc.begun != 1 || len(sc.finished) != 1 { + t.Fatalf("screen begun %d, finished %+v", sc.begun, sc.finished) + } + n := sc.finished[0] + if n.Tone != notice.OK || n.Headline != "Update complete" || len(n.Lines) != 1 || + !strings.HasPrefix(n.Lines[0], "New content, through ") || strings.Contains(n.Lines[0], ".mbu") || + n.Footer != notice.RemoveStick { + t.Fatalf("outcome = %+v", n) + } + if ev.last().Tone != notice.Progress { + t.Fatalf("an outcome banner was shown as well as the screen: %+v", ev.notices) + } +} + +func TestSummarize(t *testing.T) { + r := func(action, msg string) Result { return Result{Action: action, Message: msg} } + cases := []struct { + name string + results []Result + tone notice.Tone + headline string + lines []string + }{ + {"installed", []Result{r(ActionInstalled, "Board app 2.1.0"), r(ActionOutdated, "old")}, + notice.OK, "Update complete", []string{"Board app 2.1.0"}}, + {"partial", []Result{r(ActionInstalled, "Board app 2.1.0"), r(ActionRejected, "bad")}, + notice.Problem, "Some updates were not installed", []string{"bad", "Board app 2.1.0"}}, + {"failed", []Result{r(ActionRejected, "a"), r(ActionRejected, "b"), r(ActionRejected, "c"), r(ActionRejected, "d")}, + notice.Problem, "Update not installed", []string{"a", "b", "and 2 more"}}, + {"up to date", []Result{r(ActionUnchanged, "same"), r(ActionOutdated, "older"), r(ActionSkipped, "x")}, + notice.Neutral, "Already up to date", []string{"same", "older"}}, + {"foreign", []Result{r(ActionSkipped, "x"), r(ActionSkipped, "y")}, + notice.Neutral, "Nothing here is for this board", []string{"These updates are for other MusallahBoards"}}, + } + for _, tc := range cases { + n := Summarize(Batch{Results: tc.results}) + if n.Tone != tc.tone || n.Headline != tc.headline || strings.Join(n.Lines, "|") != strings.Join(tc.lines, "|") { + t.Errorf("%s: %+v", tc.name, n) + } + } +} + +func TestUSBNoticeIsShown(t *testing.T) { + im, l, ev, _ := setup(t) + os.MkdirAll(l.Inbox(), 0o770) + raw, _ := json.Marshal(notice.New(notice.Neutral, "No updates on this USB stick", "Put the .mbu files at the top")) + os.WriteFile(filepath.Join(l.Inbox(), "usb-sda1"+NoticeSuffix), raw, 0o644) + im.showNotices(l.Inbox()) + if len(ev.notices) != 1 || ev.notices[0].Headline != "No updates on this USB stick" { + t.Fatalf("notices = %+v", ev.notices) + } + if _, err := os.Stat(filepath.Join(l.Inbox(), "usb-sda1"+NoticeSuffix)); err == nil { + t.Fatal("notice file left behind") } } diff --git a/internal/importer/inbox.go b/internal/importer/inbox.go index 7b62448..749de1f 100644 --- a/internal/importer/inbox.go +++ b/internal/importer/inbox.go @@ -10,6 +10,7 @@ import ( "time" "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/notice" ) // InboxPoll is how often the inbox is scanned. Polling rather than inotify: @@ -17,6 +18,10 @@ import ( // file dropped while the daemon was restarting. const InboxPoll = 2 * time.Second +// NoticeSuffix marks a banner dropped in the inbox by the root USB helper +// (usb-.notice.json, a notice.Notice), for the daemon to show. +const NoticeSuffix = ".notice.json" + // InboxResult is what the daemon writes to inbox/results/.json. type InboxResult struct { Result @@ -33,6 +38,7 @@ func (im *Importer) RunInbox(ctx context.Context) { t := time.NewTicker(InboxPoll) defer t.Stop() for { + im.showNotices(inbox) files := pending(inbox) if len(files) > 0 { // Group by source so a USB stick and a CLI import that land in @@ -65,9 +71,13 @@ func (im *Importer) RunInbox(ctx context.Context) { } if b.AgentStaged { // The updater is about to restart us. Stop taking work - // so the rest waits for the new agent. - <-ctx.Done() - return + // so the rest waits for the new agent, unless the + // updater refuses it and this agent carries on. + select { + case <-ctx.Done(): + return + case <-im.awaitReplacement(): + } } } pruneResults(results, im.d.Now()) @@ -80,6 +90,39 @@ func (im *Importer) RunInbox(ctx context.Context) { } } +// showNotices passes on the banners the USB helper left, then deletes them. +func (im *Importer) showNotices(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + n := e.Name() + if !e.Type().IsRegular() || strings.HasPrefix(n, ".") || !strings.HasSuffix(n, NoticeSuffix) { + continue + } + path := filepath.Join(dir, n) + raw, err := os.ReadFile(path) + os.Remove(path) + var msg notice.Notice + if err != nil || len(raw) > 16<<10 || json.Unmarshal(raw, &msg) != nil || msg.Headline == "" { + continue + } + switch msg.Tone { + case notice.Progress, notice.OK, notice.Neutral, notice.Problem: + default: + msg.Tone = notice.Neutral + } + footer := msg.Footer + msg = notice.New(msg.Tone, msg.Headline, msg.Lines...) + msg.Footer = footer + im.d.Logger.Info("usb notice", "tone", msg.Tone, "headline", msg.Headline, "lines", msg.Lines) + if im.d.Events != nil { + im.d.Events.Notice(msg) + } + } +} + // pending lists complete *.mbu files in dir, oldest name first. Writers copy // to a .part name and rename, so a listed file is complete. func pending(dir string) []string { diff --git a/internal/localserver/server.go b/internal/localserver/server.go index abfc246..b8c7836 100644 --- a/internal/localserver/server.go +++ b/internal/localserver/server.go @@ -11,6 +11,7 @@ import ( "context" "encoding/json" "errors" + "html" "io" "io/fs" "log/slog" @@ -23,9 +24,11 @@ import ( "strings" "time" + "github.com/LensBridge/agent/internal/clock" "github.com/LensBridge/agent/internal/events" "github.com/LensBridge/agent/internal/mbu" "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/updates" "github.com/LensBridge/agent/internal/updatescreen" updateanim "github.com/LensBridge/agent/update-anim" ) @@ -57,8 +60,17 @@ type Deps struct { Weather func() (json.RawMessage, bool) // UpdateActive reports whether the update screen is up. May be nil. UpdateActive func() bool - Logger *slog.Logger - Now func() time.Time + // Updates reports the software waiting to install. May be nil. + Updates func() updates.Info + // Clock says whether the board's clock can be believed. May be nil. + Clock func() clock.Info + // ServicePort and USBImport are the board's offline routes (agent.toml). + ServicePort, USBImport bool + // Backend reports the backend connection (wsclient.ConnState), or nil + // when there is none. May be nil. + Backend func() any + Logger *slog.Logger + Now func() time.Time } // Server serves the kiosk. @@ -113,6 +125,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusNotFound, "not found") case strings.HasPrefix(p, "/media/"): s.handleMedia(w, r) + case p == "/_mb/alive.gif": + // The kiosk's starting page (packaging/starting.html) loads this to + // see whether the agent answers; an image is the one thing a + // file:// page can tell loaded from failed. + w.Header().Set("Cache-Control", cacheNoStore) + w.Header().Set("Content-Type", "image/gif") + _, _ = w.Write(aliveGIF) case p == updatescreen.Path: w.Header().Set("Cache-Control", cacheNoStore) w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -125,12 +144,24 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Status is the current /api/local/status object. func (s *Server) Status() Status { st := BuildStatus(s.d.Layout, s.d.DeviceID, s.d.AgentVersion, s.d.Now()) + st.ServicePort, st.USBImport = s.d.ServicePort, s.d.USBImport + if s.d.Backend != nil { + st.Backend = s.d.Backend() + } if s.d.Sync != nil { st.Sync = s.d.Sync() } if s.d.UpdateActive != nil { st.Update.Active = s.d.UpdateActive() } + if s.d.Updates != nil { + info := s.d.Updates() + st.Updates = &info + } + if s.d.Clock != nil { + c := s.d.Clock() + st.Clock = &c + } return st } @@ -221,7 +252,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", cacheNoStore) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) - _, _ = io.WriteString(w, noAppPage) + _, _ = io.WriteString(w, s.noAppPage()) return } fsys := os.DirFS(a.Dir) @@ -301,16 +332,35 @@ func writeJSONError(w http.ResponseWriter, code int, msg string) { } // noAppPage is shown until a board app is installed. It polls, so the board -// appears on its own once an app package lands. -const noAppPage = ` +// appears on its own once an app package lands. It suggests only the offline +// routes this board accepts. +func (s *Server) noAppPage() string { + var routes []string + if s.d.USBImport { + routes = append(routes, "plug in a USB stick holding a MusallahBoard update") + } + if s.d.ServicePort { + routes = append(routes, "connect a laptop or phone to the board's ethernet port and open http://10.77.0.1/") + } + offline := "Without internet, it needs to be set up for USB sticks or its ethernet service port first." + if len(routes) > 0 { + offline = "Without internet, " + strings.Join(routes, ", or ") + "." + } + return strings.Replace(noAppHTML, "{{offline}}", html.EscapeString(offline), 1) +} + +// aliveGIF is a transparent 1x1 GIF. +var aliveGIF = []byte("GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00" + + "!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;") + +const noAppHTML = ` MusallahBoard +h1{font-weight:300;font-size:5vmin;margin:0 0 2.5vmin}p{font-size:2.6vmin;line-height:1.45;opacity:.8;max-width:60ch;margin:0 auto}

Waiting for the board app

-

This board has no board app installed yet. Online boards download it automatically. -Without internet, plug in a USB stick holding a MusallahBoard update, or connect a laptop or phone -to the board's ethernet port and open http://10.77.0.1/.

+

This board has no board app installed yet. Online boards download it automatically within a few minutes. +{{offline}}

` diff --git a/internal/localserver/server_test.go b/internal/localserver/server_test.go index 019bb95..643eae4 100644 --- a/internal/localserver/server_test.go +++ b/internal/localserver/server_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/ed25519" "encoding/json" + "github.com/LensBridge/agent/internal/clock" "net/http" "net/http/httptest" "os" @@ -149,3 +150,48 @@ func TestStatus(t *testing.T) { t.Errorf("status %+v", st) } } + +func TestBoardReport(t *testing.T) { + days, stale := 5, 0 + errMsg := "no route to the backend" + st := Status{ + App: &AppInfo{Version: "2.2.0"}, + Content: &ContentInfo{FirstDay: "2026-09-25", LastDay: "2026-10-01", CreatedAt: "2026-09-25T12:00:00Z", Source: "usb"}, + DaysRemaining: &days, StaleDays: &stale, + Sync: map[string]any{"enabled": true, "lastError": &errMsg}, + Clock: &clock.Info{Source: clock.SourceUnverified}, + } + r := st.Board() + if r.AppVersion != "2.2.0" || r.Content == nil || r.Content.DaysRemaining != 5 || r.Content.Source != "usb" || + r.SyncError != errMsg || r.Clock == nil || r.Clock.Trusted { + t.Fatalf("report = %+v", r) + } + if r := (Status{}).Board(); r.Content != nil || r.SyncError != "" || r.AppVersion != "" { + t.Fatalf("empty status: %+v", r) + } +} + +func TestNoAppPageOffersOnlyWorkingRoutes(t *testing.T) { + cases := []struct { + usb, port bool + want []string + not []string + }{ + {true, false, []string{"USB stick"}, []string{"10.77.0.1"}}, + {false, true, []string{"10.77.0.1"}, []string{"USB stick"}}, + {false, false, []string{"set up for USB sticks or its ethernet service port first"}, []string{"10.77.0.1"}}, + } + for _, tc := range cases { + page := (&Server{d: Deps{USBImport: tc.usb, ServicePort: tc.port}}).noAppPage() + for _, w := range tc.want { + if !strings.Contains(page, w) { + t.Errorf("usb=%v port=%v: missing %q", tc.usb, tc.port, w) + } + } + for _, n := range tc.not { + if strings.Contains(page, n) { + t.Errorf("usb=%v port=%v: offers %q", tc.usb, tc.port, n) + } + } + } +} diff --git a/internal/localserver/status.go b/internal/localserver/status.go index bcaedca..3dab340 100644 --- a/internal/localserver/status.go +++ b/internal/localserver/status.go @@ -1,11 +1,15 @@ package localserver import ( + "encoding/json" "errors" "time" + "github.com/LensBridge/agent/internal/clock" "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/selfupdate" "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/updates" ) // Status is /api/local/status (docs/architecture.md, section 7). The CLI and @@ -22,7 +26,22 @@ type Status struct { StaleDays *int `json:"staleDays"` Sync any `json:"sync"` Update UpdateInfo `json:"update"` - Error string `json:"error,omitempty"` + // Clock says whether the board's clock can be believed; only the + // daemon knows. + Clock *clock.Info `json:"clock,omitempty"` + // LastAgentUpdate is the root updater's last outcome, if any. + LastAgentUpdate *selfupdate.Outcome `json:"lastAgentUpdate"` + // ServicePort and USBImport say which offline routes this board + // accepts, so screens only suggest the ones that work. + ServicePort bool `json:"servicePort"` + USBImport bool `json:"usbImport"` + // Backend is the connection to LensBridge ({connected, since, + // lastError}), for `status`; absent without a device key. + Backend any `json:"backend,omitempty"` + // Updates is the software waiting for its install window; only the + // daemon knows it. + Updates *updates.Info `json:"updates,omitempty"` + Error string `json:"error,omitempty"` } // AppInfo describes the installed app. @@ -51,7 +70,8 @@ type UpdateInfo struct { // BuildStatus reads the installed state. It never fails: a board must always // be able to say what it has, including that something is broken. func BuildStatus(l store.Layout, deviceID, agentVersion string, now time.Time) Status { - s := Status{LocalAPI: importer.LocalAPIVersion, AgentVersion: agentVersion, DeviceID: deviceID} + s := Status{LocalAPI: importer.LocalAPIVersion, AgentVersion: agentVersion, DeviceID: deviceID, + LastAgentUpdate: selfupdate.ReadOutcome(l)} if a, err := l.CurrentApp(); err == nil { s.App = &AppInfo{Version: a.Manifest.Version} } @@ -75,3 +95,64 @@ func BuildStatus(l store.Layout, deviceID, agentVersion string, now time.Time) S } return s } + +// BoardReport is what the board tells LensBridge about itself with every +// heartbeat, for the admin portal: what it runs and shows, what is waiting to +// install, how its last agent update went, and whether its clock is right. +type BoardReport struct { + AppVersion string `json:"appVersion,omitempty"` + Content *BoardContent `json:"content,omitempty"` + Updates *updates.Info `json:"updates,omitempty"` + LastAgentUpdate *selfupdate.Outcome `json:"lastAgentUpdate,omitempty"` + Clock *clock.Info `json:"clock,omitempty"` + ServicePort bool `json:"servicePort"` + USBImport bool `json:"usbImport"` + // SyncError is why the last content sync failed, if it did. + SyncError string `json:"syncError,omitempty"` + // Error is set when the installed content cannot be read. + Error string `json:"error,omitempty"` +} + +// BoardContent is the installed content, as the portal shows it. +type BoardContent struct { + FirstDay string `json:"firstDay"` + LastDay string `json:"lastDay"` + CreatedAt string `json:"createdAt"` + // Timezone is the board's, so "days left" can be worked out again + // from lastDay when the report is old. + Timezone string `json:"timezone"` + Source string `json:"source,omitempty"` + InstalledAt string `json:"installedAt,omitempty"` + DaysRemaining int `json:"daysRemaining"` + StaleDays int `json:"staleDays"` +} + +// Board is the heartbeat's report, taken from the status. +func (s Status) Board() BoardReport { + r := BoardReport{Updates: s.Updates, LastAgentUpdate: s.LastAgentUpdate, Clock: s.Clock, Error: s.Error, + ServicePort: s.ServicePort, USBImport: s.USBImport} + if s.App != nil { + r.AppVersion = s.App.Version + } + if c := s.Content; c != nil { + r.Content = &BoardContent{FirstDay: c.FirstDay, LastDay: c.LastDay, CreatedAt: c.CreatedAt, + Timezone: c.Timezone, Source: c.Source, InstalledAt: c.InstalledAt} + if s.DaysRemaining != nil { + r.Content.DaysRemaining = *s.DaysRemaining + } + if s.StaleDays != nil { + r.Content.StaleDays = *s.StaleDays + } + } + // Sync is the syncer's status object, whatever its type; only its + // lastError is reported. + if raw, err := json.Marshal(s.Sync); err == nil { + var sync struct { + LastError *string `json:"lastError"` + } + if json.Unmarshal(raw, &sync) == nil && sync.LastError != nil { + r.SyncError = *sync.LastError + } + } + return r +} diff --git a/internal/notice/notice.go b/internal/notice/notice.go new file mode 100644 index 0000000..3ba292e --- /dev/null +++ b/internal/notice/notice.go @@ -0,0 +1,80 @@ +// Package notice is what the board tells the people in front of it about an +// update: a headline and a few short lines, in plain language, with a tone +// (docs/architecture.md, section 8). The same notice is shown on the update +// screen when something was installed, or as a banner over the running board +// when nothing was (already up to date, a USB stick with nothing on it, a +// package that could not be verified). +package notice + +import "fmt" + +// Tone is how a notice looks. +type Tone string + +const ( + // Progress is something under way ("Reading USB stick…"). It stays up + // until replaced. + Progress Tone = "progress" + OK Tone = "ok" + // Neutral is "nothing needed doing": already up to date, nothing for + // this board. + Neutral Tone = "neutral" + Problem Tone = "problem" +) + +// MaxLines is how many lines a notice shows; more are folded into +// "and N more". +const MaxLines = 3 + +// Notice is one message for the screen. +type Notice struct { + Tone Tone `json:"tone"` + Headline string `json:"headline"` + Lines []string `json:"lines"` + // Footer is a standing instruction under the lines, shown whatever they + // say: "You can remove the USB stick". + Footer string `json:"footer,omitempty"` + // Seconds is how long a banner stays up; 0 means until replaced. + Seconds int `json:"seconds"` +} + +// Seconds a banner of each tone stays up: long enough to read from across a +// room, and longer for a problem, which someone may need to act on. +func DefaultSeconds(t Tone) int { + switch t { + case Progress: + return 0 + case Problem: + return 14 + } + return 8 +} + +// New builds a notice with the default duration for its tone and the lines +// folded to MaxLines. +func New(t Tone, headline string, lines ...string) Notice { + return Notice{Tone: t, Headline: headline, Lines: Fold(lines), Seconds: DefaultSeconds(t)} +} + +// RemoveStick is the footer of every outcome for a USB stick: the helper +// unmounts it before the board looks at what it copied. +const RemoveStick = "You can remove the USB stick" + +// Fold drops empty and repeated lines and keeps at most MaxLines, replacing +// the rest with "and N more". +func Fold(lines []string) []string { + out := []string{} + seen := map[string]bool{} + for _, l := range lines { + if l == "" || seen[l] { + continue + } + seen[l] = true + out = append(out, l) + } + if len(out) > MaxLines { + more := len(out) - (MaxLines - 1) + out = append(out[:MaxLines-1], fmt.Sprintf("and %d more", more)) + } + return out +} diff --git a/internal/notice/notice_test.go b/internal/notice/notice_test.go new file mode 100644 index 0000000..e70e194 --- /dev/null +++ b/internal/notice/notice_test.go @@ -0,0 +1,22 @@ +package notice + +import ( + "reflect" + "testing" +) + +func TestFold(t *testing.T) { + cases := []struct { + in, want []string + }{ + {nil, []string{}}, + {[]string{"a", "", "a", "b"}, []string{"a", "b"}}, + {[]string{"a", "b", "c"}, []string{"a", "b", "c"}}, + {[]string{"a", "b", "c", "d", "e"}, []string{"a", "b", "and 3 more"}}, + } + for _, tc := range cases { + if got := Fold(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Errorf("Fold(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 7d22e86..423a831 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -149,6 +149,13 @@ func (u *Updater) Apply(ctx context.Context) (Outcome, error) { if werr := u.writeOutcome(out); werr != nil { u.Logf("could not write %s: %v", u.Layout.AgentLastUpdate(), werr) } + // Only now start the previous agent again: it reads the outcome when it + // starts, to tell the people at the board what happened. + if out.Status == StatusRolledBack { + if o, rerr := u.Run(ctx, "systemctl", "restart", AgentUnit); rerr != nil { + u.Logf("restarting the previous agent failed: %v %s", rerr, firstLine(o)) + } + } return out, err } @@ -231,11 +238,17 @@ func (u *Updater) apply(ctx context.Context, out *Outcome) error { if err := u.Healthy(ctx, m.Version); err != nil { return u.rollback(ctx, m.Version, fmt.Sprintf("agent %s did not come up healthy: %v", m.Version, err)) } + // The enrollment splash ships inside the binary; bring it up to date with + // it. Best effort: the splash is only seen before enrollment. + if o, err := u.Run(ctx, u.Binary, "splash", "install"); err != nil { + u.Logf("could not refresh the enrollment splash: %v %s", err, firstLine(o)) + } return nil } -// rollback restores the previous agent, restarts it and records the version -// as rejected so the release channel or a USB stick cannot loop on it. +// rollback restores the previous agent and records the version as rejected +// so the release channel or a USB stick cannot loop on it. Apply restarts +// the previous agent once the outcome is written. func (u *Updater) rollback(ctx context.Context, version, reason string) error { u.Logf("%s; rolling back to %s", reason, u.CurrentVersion) msg := reason + "; rolled back to " + u.CurrentVersion @@ -250,9 +263,6 @@ func (u *Updater) rollback(ctx context.Context, version, reason string) error { }); err != nil { msg += fmt.Sprintf(" (could not record %s as rejected: %v)", version, err) } - if o, err := u.Run(ctx, "systemctl", "restart", AgentUnit); err != nil { - msg += fmt.Sprintf(" (restarting the previous agent failed: %v %s)", err, firstLine(o)) - } return errors.New(msg) } @@ -277,6 +287,19 @@ func (u *Updater) extract(pkg *mbu.Package, binary string) error { return os.Chmod(u.NewBinary, 0o755) } +// ReadOutcome is the last outcome recorded in l, or nil if there is none. +func ReadOutcome(l store.Layout) *Outcome { + raw, err := os.ReadFile(l.AgentLastUpdate()) + if err != nil { + return nil + } + var o Outcome + if json.Unmarshal(raw, &o) != nil || o.At == "" { + return nil + } + return &o +} + func (u *Updater) writeOutcome(o Outcome) error { raw, err := json.MarshalIndent(o, "", " ") if err != nil { diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index a14f002..198e6dc 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -133,7 +133,8 @@ func TestApplyOK(t *testing.T) { t.Errorf("%s left behind", p) } } - want := []string{".musallahboard-agent.new version", "systemctl restart musallahboard-agent.service"} + want := []string{".musallahboard-agent.new version", "systemctl restart musallahboard-agent.service", + "musallahboard-agent splash install"} if fmt.Sprint(h.cmds) != fmt.Sprint(want) { t.Errorf("commands = %q, want %q", h.cmds, want) } @@ -150,6 +151,17 @@ func TestApplyRollsBack(t *testing.T) { h := newHarness(t, true) h.stage(t, "0.4.0", "arm64", releaseKey) h.healthy = errors.New("it reports version 0.3.0") + // The previous agent reads the outcome when it starts, so it must be + // written before the second restart. + run := h.u.Run + outcomeAtRestart := []bool{} + h.u.Run = func(ctx context.Context, name string, args ...string) (string, error) { + if name == "systemctl" { + _, err := os.Stat(h.u.Layout.AgentLastUpdate()) + outcomeAtRestart = append(outcomeAtRestart, err == nil) + } + return run(ctx, name, args...) + } out, err := h.u.Apply(context.Background()) if err == nil || out.Status != StatusRolledBack { t.Fatalf("outcome = %+v, err = %v", out, err) @@ -170,6 +182,9 @@ func TestApplyRollsBack(t *testing.T) { if restarts != 2 { t.Errorf("restarts = %d, want 2 (%q)", restarts, h.cmds) } + if len(outcomeAtRestart) != 2 || !outcomeAtRestart[1] { + t.Errorf("outcome written before the previous agent restarted: %v", outcomeAtRestart) + } if o := lastUpdate(t, h); o.Status != StatusRolledBack || !strings.Contains(o.Message, "rolled back") { t.Errorf("last-update = %+v", o) } diff --git a/internal/splash/splash.go b/internal/splash/splash.go index e4c5d23..ce0f138 100644 --- a/internal/splash/splash.go +++ b/internal/splash/splash.go @@ -11,7 +11,7 @@ // // The page contract (see packaging/waiting.html): // -// window.MusallahBoard.setNetInfo({ipv4, ssid, hostname}) → renders, no return +// window.MusallahBoard.setNetInfo({ipv4, ssid, hostname, agentVersion}) → renders, no return // // Pushes are idempotent and stateless: the caller re-sends on a timer, so a // kiosk restart (which wipes the injected values) self-heals on the next tick @@ -48,8 +48,11 @@ const pushTimeout = 5 * time.Second // rather than string concatenation: hostname and SSID are attacker-adjacent // (a hostile AP picks the SSID) and must not be able to break out of the // literal and run as code in the kiosk browser. -func netInfoScript(info netinfo.Info) (string, error) { - payload, err := json.Marshal(info) +func netInfoScript(info netinfo.Info, agentVersion string) (string, error) { + payload, err := json.Marshal(struct { + netinfo.Info + AgentVersion string `json:"agentVersion,omitempty"` + }{info, agentVersion}) if err != nil { return "", fmt.Errorf("splash: marshal net info: %w", err) } @@ -61,13 +64,14 @@ func netInfoScript(info netinfo.Info) (string, error) { })()`, nil } -// PushNetInfo renders info onto the enrollment splash. +// PushNetInfo renders info, and the running agent's version, onto the +// enrollment splash. // // Returns ErrNoSplash when the current page has no setNetInfo hook, and a // wrapped transport error when Chromium is unreachable (not yet started, or // mid-restart) — both are expected states while a board waits to be enrolled. -func PushNetInfo(ctx context.Context, ev Evaluator, info netinfo.Info) error { - script, err := netInfoScript(info) +func PushNetInfo(ctx context.Context, ev Evaluator, info netinfo.Info, agentVersion string) error { + script, err := netInfoScript(info, agentVersion) if err != nil { return err } diff --git a/internal/splash/splash_test.go b/internal/splash/splash_test.go index 6ff4cd9..834ae11 100644 --- a/internal/splash/splash_test.go +++ b/internal/splash/splash_test.go @@ -34,12 +34,15 @@ func TestPushNetInfoApplied(t *testing.T) { ev := &fakeEval{applied: true} info := netinfo.Info{IPv4: []string{"192.168.1.42"}, SSID: "MSA", Hostname: "lobby"} - if err := PushNetInfo(context.Background(), ev, info); err != nil { + if err := PushNetInfo(context.Background(), ev, info, "0.3.0"); err != nil { t.Fatalf("PushNetInfo() = %v, want nil", err) } if !strings.Contains(ev.gotExpr, "192.168.1.42") { t.Errorf("expression does not carry the address:\n%s", ev.gotExpr) } + if !strings.Contains(ev.gotExpr, `"agentVersion":"0.3.0"`) { + t.Errorf("expression does not carry the agent version:\n%s", ev.gotExpr) + } if !strings.Contains(ev.gotExpr, "setNetInfo") { t.Errorf("expression does not call the page hook:\n%s", ev.gotExpr) } @@ -49,14 +52,14 @@ func TestPushNetInfoApplied(t *testing.T) { // The poll loop must be able to tell that apart from a real failure. func TestPushNetInfoNoHook(t *testing.T) { ev := &fakeEval{applied: false} - if err := PushNetInfo(context.Background(), ev, netinfo.Info{}); !errors.Is(err, ErrNoSplash) { + if err := PushNetInfo(context.Background(), ev, netinfo.Info{}, ""); !errors.Is(err, ErrNoSplash) { t.Fatalf("PushNetInfo() = %v, want ErrNoSplash", err) } } func TestPushNetInfoUndefinedIsNoSplash(t *testing.T) { ev := &fakeEval{err: cdp.ErrEvalUndefined} - if err := PushNetInfo(context.Background(), ev, netinfo.Info{}); !errors.Is(err, ErrNoSplash) { + if err := PushNetInfo(context.Background(), ev, netinfo.Info{}, ""); !errors.Is(err, ErrNoSplash) { t.Fatalf("PushNetInfo() = %v, want ErrNoSplash", err) } } @@ -65,7 +68,7 @@ func TestPushNetInfoUndefinedIsNoSplash(t *testing.T) { func TestPushNetInfoTransportErrorPropagates(t *testing.T) { want := fmt.Errorf("chrome not reachable") ev := &fakeEval{err: want} - err := PushNetInfo(context.Background(), ev, netinfo.Info{}) + err := PushNetInfo(context.Background(), ev, netinfo.Info{}, "") if !errors.Is(err, want) { t.Fatalf("PushNetInfo() = %v, want %v", err, want) } @@ -84,7 +87,7 @@ func TestNetInfoScriptEscapesHostileFields(t *testing.T) { Hostname: "", } - script, err := netInfoScript(info) + script, err := netInfoScript(info, "") if err != nil { t.Fatalf("netInfoScript() error = %v", err) } @@ -114,7 +117,7 @@ func TestNetInfoScriptEscapesHostileFields(t *testing.T) { } func TestNetInfoScriptOfflineDeviceStillPushes(t *testing.T) { - script, err := netInfoScript(netinfo.Info{Hostname: "lobby"}) + script, err := netInfoScript(netinfo.Info{Hostname: "lobby"}, "") if err != nil { t.Fatalf("netInfoScript() error = %v", err) } diff --git a/internal/state/state.go b/internal/state/state.go index 2054e15..ddc4ffa 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -30,6 +30,12 @@ type State struct { // SignedClockFloor is the newest createdAt of any verified package: the // part of the floor that is proven rather than remembered. SignedClockFloor int64 `json:"signedClockFloor,omitempty"` + // AgentUpdateSeen is the "at" of the last agent self-update outcome the + // board has shown, so each is shown once. + AgentUpdateSeen string `json:"agentUpdateSeen,omitempty"` + // ClockSetBoot is the kernel boot id during which an uploader's time + // confirmed the clock. It counts only until the next boot. + ClockSetBoot string `json:"clockSetBoot,omitempty"` } // MaxWallAheadOfSigned bounds how far a remembered wall clock may raise the diff --git a/internal/store/layout.go b/internal/store/layout.go index 03b2fc6..6a1a01e 100644 --- a/internal/store/layout.go +++ b/internal/store/layout.go @@ -41,8 +41,22 @@ func (l Layout) AppReleases() string { return filepath.Join(l.appDir(), "rel func (l Layout) AppCurrent() string { return filepath.Join(l.appDir(), "current") } func (l Layout) AgentStagedDir() string { return filepath.Join(l.Root, "agent", "staged") } func (l Layout) AgentLastUpdate() string { return filepath.Join(l.Root, "agent", "last-update.json") } -func (l Layout) Inbox() string { return filepath.Join(l.Root, "inbox") } -func (l Layout) InboxResults() string { return filepath.Join(l.Inbox(), "results") } + +// AgentPendingNotice is the outcome of the batch that staged an agent, left +// for the agent that starts next to show (package agentupdate). +func (l Layout) AgentPendingNotice() string { + return filepath.Join(l.Root, "agent", "pending-notice.json") +} +func (l Layout) Inbox() string { return filepath.Join(l.Root, "inbox") } +func (l Layout) InboxResults() string { return filepath.Join(l.Inbox(), "results") } + +// Updates holds software downloaded from the release channels until its +// install window (section 9.4), and the CLI's install-now request. +func (l Layout) Updates() string { return filepath.Join(l.Root, "updates") } +func (l Layout) UpdateRequest() string { return filepath.Join(l.Updates(), "install-now") } +func (l Layout) UpdateRequestResult() string { + return filepath.Join(l.Updates(), "install-now.result.json") +} // StagedPackage and StagedReady are the self-update hand-off (section 12). func (l Layout) StagedPackage() string { return filepath.Join(l.AgentStagedDir(), "package.mbu") } diff --git a/internal/updates/updates.go b/internal/updates/updates.go new file mode 100644 index 0000000..a118b04 --- /dev/null +++ b/internal/updates/updates.go @@ -0,0 +1,426 @@ +// Package updates holds board software (the app and the agent) that the +// release channels found, and installs it in the board's quiet window rather +// than the moment it arrives (docs/architecture.md, section 9.4). While +// something waits, /api/local/status says what and when, and the board app +// puts it on the ticker. +// +// Only background channel updates wait. A package someone brings to the board +// (USB stick, upload, CLI) installs at once, because that person is standing +// there, and a board with no app installs its first one at once, because it +// has nothing else to show. +package updates + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "github.com/LensBridge/agent/internal/fsutil" + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/store" +) + +// Window is how long after the install time a waiting update still installs. +// A board that was off at 23:00 but on by 01:00 still updates that night; one +// switched on in the morning waits for the next night. +const Window = 4 * time.Hour + +const ( + checkInterval = 30 * time.Second + requestPoll = 2 * time.Second +) + +// order is the order updates are listed and installed in. The importer sorts a +// batch itself; this only keeps the status stable. +var order = []mbu.Type{mbu.TypeAgent, mbu.TypeApp} + +// Update is one waiting update. +type Update struct { + Type mbu.Type `json:"type"` + Version string `json:"version"` + Description string `json:"description"` +} + +// Info is the "updates" object of /api/local/status. +type Info struct { + Available []Update `json:"available"` + // InstallTime is the configured install time, "HH:MM" in the board's + // time zone. + InstallTime string `json:"installTime"` + // InstallAt is when the waiting updates install (RFC 3339, in the + // board's time zone), or null when nothing waits. It is the current time + // when the window is already open. + InstallAt *string `json:"installAt"` + Installing bool `json:"installing"` + // AutoUpdate is whether the board follows the release channels at all + // (auto_update in agent.toml). + AutoUpdate bool `json:"autoUpdate"` + // LastCheckAt is when the release channels were last asked (RFC 3339), + // null before the first check; LastCheckError is why that check failed, + // if it did. Without them "nothing waiting" could mean "up to date" or + // "cannot tell". + LastCheckAt *string `json:"lastCheckAt"` + LastCheckError string `json:"lastCheckError,omitempty"` +} + +// Outcome is the result of an install-now request (CLI or remote command). +type Outcome struct { + At string `json:"at"` + // CheckError is why the release channels could not be asked, if they + // could not. Updates found earlier still install. + CheckError string `json:"checkError,omitempty"` + Results []importer.Result `json:"results"` +} + +// Deps is what a Scheduler needs. +type Deps struct { + Layout store.Layout + Importer *importer.Importer + // Check asks the release channels for new software, which they hand to + // Offer. Nil when the board cannot reach them (no device key). + Check func(ctx context.Context) error + // Changed is told whenever Info changes. May be nil. + Changed func(Info) + Hour, Minute int + // AutoUpdate is reported in Info; the scheduler itself holds and + // installs whatever it is offered either way. + AutoUpdate bool + Logger *slog.Logger + Now func() time.Time +} + +// Scheduler holds waiting updates and installs them. +type Scheduler struct { + d Deps + log *slog.Logger + kick chan struct{} + + // installMu is held while packages move in or out of the updates + // directory, so an Offer never replaces a file the importer is reading. + installMu sync.Mutex + + mu sync.Mutex + pending map[mbu.Type]*mbu.Manifest + installing bool + lastCheckAt time.Time + lastCheckErr string +} + +// New returns a Scheduler holding whatever an earlier run left waiting. +func New(d Deps) *Scheduler { + if d.Now == nil { + d.Now = time.Now + } + if d.Logger == nil { + d.Logger = slog.New(slog.DiscardHandler) + } + s := &Scheduler{ + d: d, + log: d.Logger.With("component", "updates"), + kick: make(chan struct{}, 1), + pending: map[mbu.Type]*mbu.Manifest{}, + } + s.load() + return s +} + +func (s *Scheduler) path(t mbu.Type) string { + return filepath.Join(s.d.Layout.Updates(), string(t)+".mbu") +} + +// load picks up updates downloaded before a restart. One that no longer +// passes (installed from a USB stick meanwhile, say) is dropped. +func (s *Scheduler) load() { + for _, t := range order { + p := s.path(t) + if _, err := os.Stat(p); err != nil { + continue + } + m, err := s.d.Importer.Preflight(p) + if err == nil && m.Type != t { + err = fmt.Errorf("it is a %s package", m.Type) + } + if err != nil { + s.log.Info("dropping a waiting update", "file", p, "reason", err) + _ = os.Remove(p) + continue + } + s.pending[t] = m + s.log.Info("update waiting", "update", m.Describe()) + } +} + +// Pending returns the version of kind waiting to install, or "". +func (s *Scheduler) Pending(kind mbu.Type) string { + s.mu.Lock() + defer s.mu.Unlock() + if m := s.pending[kind]; m != nil { + return m.Version + } + return "" +} + +// Offer takes a package the release channels downloaded. It is verified and +// checked now, so the board never announces something it would refuse, and +// moved into the updates directory (file must be on the same filesystem). +func (s *Scheduler) Offer(file string) error { + m, err := s.d.Importer.Preflight(file) + if err != nil { + return err + } + if m.Type == mbu.TypeApp && m.App.LocalAPI > importer.LocalAPIVersion && s.Pending(mbu.TypeAgent) == "" { + return fmt.Errorf("board app %s needs a newer agent (local API %d; this agent serves %d); it is offered again once the agent is updated", + m.Version, m.App.LocalAPI, importer.LocalAPIVersion) + } + + s.installMu.Lock() + err = os.MkdirAll(s.d.Layout.Updates(), 0o750) + if err == nil { + err = os.Rename(file, s.path(m.Type)) + } + if err == nil { + s.mu.Lock() + s.pending[m.Type] = m + s.mu.Unlock() + } + s.installMu.Unlock() + if err != nil { + return fmt.Errorf("could not keep %s for later: %w", m.Describe(), err) + } + + info := s.Info() + at := "" + if info.InstallAt != nil { + at = *info.InstallAt + } + s.log.Info("update available", "update", m.Describe(), "installAt", at) + s.changed(info) + select { + case s.kick <- struct{}{}: + default: + } + return nil +} + +// Info is the current status. +func (s *Scheduler) Info() Info { + now := s.d.Now() + s.mu.Lock() + defer s.mu.Unlock() + info := Info{ + Available: []Update{}, + InstallTime: fmt.Sprintf("%02d:%02d", s.d.Hour, s.d.Minute), + Installing: s.installing, + AutoUpdate: s.d.AutoUpdate, + LastCheckError: s.lastCheckErr, + } + if !s.lastCheckAt.IsZero() { + at := s.lastCheckAt.UTC().Format(time.RFC3339) + info.LastCheckAt = &at + } + for _, t := range order { + if m := s.pending[t]; m != nil { + info.Available = append(info.Available, Update{Type: t, Version: m.Version, Description: m.Describe()}) + } + } + if len(info.Available) > 0 { + at := s.installAt(now).Format(time.RFC3339) + info.InstallAt = &at + } + return info +} + +// Run installs waiting updates when their window opens, and serves the CLI's +// install-now requests, until ctx ends. +func (s *Scheduler) Run(ctx context.Context) { + go s.watchRequests(ctx) + t := time.NewTicker(checkInterval) + defer t.Stop() + for { + if s.due() { + s.InstallNow(ctx) + } + select { + case <-ctx.Done(): + return + case <-t.C: + case <-s.kick: + } + } +} + +// CheckDone records the outcome of a release channel check, for Info. +func (s *Scheduler) CheckDone(err error) { + s.mu.Lock() + s.lastCheckAt = s.d.Now() + s.lastCheckErr = "" + if err != nil { + s.lastCheckErr = err.Error() + } + s.mu.Unlock() +} + +// CheckAndInstall asks the release channels for new software and installs +// everything waiting, whatever the time. It is the "update now" command. +// ctx bounds the check (a remote command's deadline); an install that has +// started always finishes, because stopping one halfway helps nobody. +func (s *Scheduler) CheckAndInstall(ctx context.Context) Outcome { + var out Outcome + if s.d.Check == nil { + out.CheckError = "this board cannot reach the release channels (no device key)" + } else { + err := s.d.Check(ctx) + s.CheckDone(err) + if err != nil { + out.CheckError = err.Error() + } + } + b, _ := s.InstallNow(context.WithoutCancel(ctx)) + out.Results = b.Results + if out.Results == nil { + out.Results = []importer.Result{} + } + out.At = s.d.Now().UTC().Format(time.RFC3339) + return out +} + +// InstallNow installs everything waiting, as one batch, whatever the time. It +// reports false when nothing was waiting. +func (s *Scheduler) InstallNow(ctx context.Context) (importer.Batch, bool) { + s.installMu.Lock() + defer s.installMu.Unlock() + + s.mu.Lock() + var files []string + for _, t := range order { + if s.pending[t] != nil { + files = append(files, s.path(t)) + } + } + if len(files) == 0 { + s.mu.Unlock() + return importer.Batch{}, false + } + s.installing = true + s.mu.Unlock() + s.changed(s.Info()) + + // Source sync: the update screen goes up for software, as it should for + // anything that changes what the board runs. + b := s.d.Importer.Import(ctx, importer.SourceSync, files) + for _, r := range b.Results { + s.log.Info("update installed", "file", r.File, "action", r.Action, "message", r.Message) + } + // Installed, refused or queued behind an agent restart (the importer + // moves those into the inbox), each is done waiting here. One refused + // for a passing reason is offered again by the next channel check. + for _, f := range files { + if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) { + s.log.Warn("could not remove an installed update", "file", f, "err", err) + } + } + + s.mu.Lock() + s.pending = map[mbu.Type]*mbu.Manifest{} + s.installing = false + s.mu.Unlock() + s.changed(s.Info()) + return b, true +} + +// due reports whether waiting updates should install now: the window is +// open, or the board has no app and so nothing better to do. +func (s *Scheduler) due() bool { + s.mu.Lock() + n := len(s.pending) + s.mu.Unlock() + if n == 0 { + return false + } + if st, err := s.d.Layout.State().Load(); err == nil && st.AppVersion == "" { + return true + } + open, _ := s.window(s.d.Now()) + return open +} + +// installAt is when waiting updates install: now if the window is open, else +// the next time it opens. +func (s *Scheduler) installAt(now time.Time) time.Time { + if st, err := s.d.Layout.State().Load(); err == nil && st.AppVersion == "" { + return now.In(s.location()) + } + open, next := s.window(now) + if open { + return now.In(s.location()) + } + return next +} + +// window reports whether an install window is open at now and, if not, when +// the next one opens. A window that opened yesterday evening can still be +// open after midnight. +func (s *Scheduler) window(now time.Time) (open bool, next time.Time) { + loc := s.location() + n := now.In(loc) + for _, day := range []int{-1, 0} { + start := time.Date(n.Year(), n.Month(), n.Day()+day, s.d.Hour, s.d.Minute, 0, 0, loc) + if !n.Before(start) && n.Before(start.Add(Window)) { + return true, start + } + } + next = time.Date(n.Year(), n.Month(), n.Day(), s.d.Hour, s.d.Minute, 0, 0, loc) + if !n.Before(next) { + next = time.Date(n.Year(), n.Month(), n.Day()+1, s.d.Hour, s.d.Minute, 0, 0, loc) + } + return false, next +} + +// location is the board's time zone: the installed content's, which is the +// one its clock face shows, or the system's before any content arrives. +func (s *Scheduler) location() *time.Location { + if c, err := s.d.Layout.CurrentContent(); err == nil && c.Manifest.Content != nil { + if loc, err := time.LoadLocation(c.Manifest.Content.Timezone); err == nil { + return loc + } + } + return time.Local +} + +func (s *Scheduler) changed(info Info) { + if s.d.Changed != nil { + s.d.Changed(info) + } +} + +// watchRequests serves `musallahboard-agent update now`, which, being root +// and a separate process, asks by dropping a file and waits for the answer. +func (s *Scheduler) watchRequests(ctx context.Context) { + req, res := s.d.Layout.UpdateRequest(), s.d.Layout.UpdateRequestResult() + t := time.NewTicker(requestPoll) + defer t.Stop() + for { + if _, err := os.Stat(req); err == nil { + _ = os.Remove(req) + s.log.Info("update now requested from the command line") + out := s.CheckAndInstall(ctx) + raw, _ := json.MarshalIndent(out, "", " ") + if err := fsutil.WriteAtomic(res, append(raw, '\n'), 0o640); err != nil { + s.log.Warn("could not write the update-now result", "err", err) + } + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} diff --git a/internal/updates/updates_test.go b/internal/updates/updates_test.go new file mode 100644 index 0000000..7f593a2 --- /dev/null +++ b/internal/updates/updates_test.go @@ -0,0 +1,266 @@ +package updates + +import ( + "bytes" + "context" + "errors" + "crypto/ed25519" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/state" + "github.com/LensBridge/agent/internal/store" + "github.com/LensBridge/agent/internal/trust" +) + +var releaseKey = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)) + +type env struct { + l store.Layout + im *importer.Importer + s *Scheduler + now time.Time + got []Info +} + +// newEnv is a scheduler over a temp store with app 2.0.0 installed, agent +// 0.3.0 running and the install time at 23:00 UTC (no content, so the zone +// is the system's; tests pin it to UTC). +func newEnv(t *testing.T) *env { + t.Helper() + time.Local = time.UTC + e := &env{l: store.Layout{Root: t.TempDir()}, now: time.Date(2026, 9, 25, 14, 0, 0, 0, time.UTC)} + ring := trust.NewRing(nil, []ed25519.PublicKey{releaseKey.Public().(ed25519.PublicKey)}) + e.im = importer.New(importer.Deps{ + Layout: e.l, DeviceID: "board", AgentVersion: "0.3.0", + Ring: func() (*trust.Ring, error) { return ring, nil }, + Now: func() time.Time { return e.now }, + }) + setApp(t, e.l, "2.0.0") + e.s = e.newScheduler() + return e +} + +func (e *env) newScheduler() *Scheduler { + return New(Deps{ + Layout: e.l, Importer: e.im, Hour: 23, + Changed: func(i Info) { e.got = append(e.got, i) }, + Now: func() time.Time { return e.now }, + }) +} + +func setApp(t *testing.T, l store.Layout, v string) { + t.Helper() + if _, err := l.State().Update(func(s *state.State) error { s.AppVersion = v; return nil }); err != nil { + t.Fatal(err) + } +} + +// write builds a package into the inbox, where the release channels download. +func (e *env) write(t *testing.T, m mbu.Manifest, src []mbu.Source) string { + t.Helper() + var buf bytes.Buffer + if err := mbu.Build(&buf, m, src, []ed25519.PrivateKey{releaseKey}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(e.l.Inbox(), ".download") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, string(m.Type)+"-"+m.Version+".mbu") + if err := os.WriteFile(p, buf.Bytes(), 0o640); err != nil { + t.Fatal(err) + } + return p +} + +func (e *env) app(t *testing.T, version string, localAPI int) string { + return e.write(t, mbu.Manifest{Type: mbu.TypeApp, Version: version, App: &mbu.AppInfo{LocalAPI: localAPI}}, + []mbu.Source{{Path: "index.html", Data: []byte("" + version)}}) +} + +func (e *env) agent(t *testing.T, version string) string { + return e.write(t, mbu.Manifest{Type: mbu.TypeAgent, Version: version, + Agent: &mbu.AgentInfo{Arch: runtime.GOARCH, Binary: "musallahboard-agent"}}, + []mbu.Source{{Path: "musallahboard-agent", Data: []byte("#!/bin/sh\necho " + version)}}) +} + +func TestOfferWaitsForTheWindow(t *testing.T) { + e := newEnv(t) + if err := e.s.Offer(e.app(t, "2.1.0", 2)); err != nil { + t.Fatal(err) + } + info := e.s.Info() + if len(info.Available) != 1 || info.Available[0].Version != "2.1.0" || info.Available[0].Description != "board app 2.1.0" { + t.Fatalf("available = %+v", info.Available) + } + if info.InstallAt == nil || *info.InstallAt != "2026-09-25T23:00:00Z" { + t.Fatalf("installAt = %v, want tonight at 23:00", info.InstallAt) + } + if len(e.got) == 0 { + t.Error("Changed was not told about the new update") + } + if e.s.due() { + t.Fatal("due at 14:00") + } + if st, _ := e.l.State().Load(); st.AppVersion != "2.0.0" { + t.Fatalf("installed %s before the window", st.AppVersion) + } + + e.now = time.Date(2026, 9, 25, 23, 0, 30, 0, time.UTC) + if !e.s.due() { + t.Fatal("not due at 23:00") + } + if _, ok := e.s.InstallNow(context.Background()); !ok { + t.Fatal("nothing installed") + } + if st, _ := e.l.State().Load(); st.AppVersion != "2.1.0" { + t.Fatalf("app = %s after the window opened", st.AppVersion) + } + if info := e.s.Info(); len(info.Available) != 0 || info.InstallAt != nil { + t.Fatalf("still waiting after install: %+v", info) + } + if _, err := os.Stat(e.s.path(mbu.TypeApp)); err == nil { + t.Error("the installed package was left in the updates directory") + } +} + +func TestWindow(t *testing.T) { + e := newEnv(t) + cases := []struct { + at time.Time + open bool + next string + }{ + {time.Date(2026, 9, 25, 22, 59, 0, 0, time.UTC), false, "2026-09-25T23:00:00Z"}, + {time.Date(2026, 9, 25, 23, 0, 0, 0, time.UTC), true, ""}, + {time.Date(2026, 9, 26, 2, 59, 0, 0, time.UTC), true, ""}, + {time.Date(2026, 9, 26, 3, 0, 0, 0, time.UTC), false, "2026-09-26T23:00:00Z"}, + {time.Date(2026, 9, 26, 8, 0, 0, 0, time.UTC), false, "2026-09-26T23:00:00Z"}, + } + for _, tc := range cases { + open, next := e.s.window(tc.at) + if open != tc.open { + t.Errorf("%s: open = %v, want %v", tc.at, open, tc.open) + } + if !open && next.Format(time.RFC3339) != tc.next { + t.Errorf("%s: next = %s, want %s", tc.at, next.Format(time.RFC3339), tc.next) + } + } +} + +func TestFirstAppInstallsAtOnce(t *testing.T) { + e := newEnv(t) + setApp(t, e.l, "") + if err := e.s.Offer(e.app(t, "2.1.0", 2)); err != nil { + t.Fatal(err) + } + if !e.s.due() { + t.Fatal("a board with no app waits for the window") + } + if info := e.s.Info(); info.InstallAt == nil || *info.InstallAt != "2026-09-25T14:00:00Z" { + t.Fatalf("installAt = %v, want now", info.InstallAt) + } +} + +func TestOfferRefusesWhatWouldNotInstall(t *testing.T) { + e := newEnv(t) + if err := e.s.Offer(e.app(t, "2.0.0", 2)); err == nil { + t.Error("offered the installed version") + } + if err := e.s.Offer(e.app(t, "1.9.0", 2)); err == nil { + t.Error("offered an older version") + } + if err := e.s.Offer(e.app(t, "3.0.0", importer.LocalAPIVersion+1)); err == nil { + t.Error("offered an app that needs a newer agent, with no agent waiting") + } + if len(e.s.Info().Available) != 0 { + t.Fatal("something is waiting") + } + + // With a new agent waiting, the app that needs it may wait too: the + // importer installs the agent first and queues the app behind it. + if err := e.s.Offer(e.agent(t, "0.4.0")); err != nil { + t.Fatal(err) + } + if err := e.s.Offer(e.app(t, "3.0.0", importer.LocalAPIVersion+1)); err != nil { + t.Fatal(err) + } + if n := len(e.s.Info().Available); n != 2 { + t.Fatalf("%d waiting, want 2", n) + } +} + +func TestInstallNowStagesAgentAndQueuesApp(t *testing.T) { + e := newEnv(t) + if err := e.s.Offer(e.agent(t, "0.4.0")); err != nil { + t.Fatal(err) + } + if err := e.s.Offer(e.app(t, "2.1.0", 2)); err != nil { + t.Fatal(err) + } + b, ok := e.s.InstallNow(context.Background()) + if !ok || !b.AgentStaged { + t.Fatalf("agent not staged: %+v", b.Results) + } + if _, err := os.Stat(e.l.StagedReady()); err != nil { + t.Fatal("no staged agent") + } + queued, _ := filepath.Glob(filepath.Join(e.l.Inbox(), "*.mbu")) + if len(queued) != 1 { + t.Fatalf("queued behind the agent: %v", queued) + } +} + +func TestRestartKeepsWaitingUpdates(t *testing.T) { + e := newEnv(t) + if err := e.s.Offer(e.app(t, "2.1.0", 2)); err != nil { + t.Fatal(err) + } + if got := e.newScheduler().Pending(mbu.TypeApp); got != "2.1.0" { + t.Fatalf("after a restart waiting = %q", got) + } + + // Installed from a USB stick meanwhile: dropped at the next start. + setApp(t, e.l, "2.1.0") + if got := e.newScheduler().Pending(mbu.TypeApp); got != "" { + t.Fatalf("still waiting for an installed version: %q", got) + } + if _, err := os.Stat(e.s.path(mbu.TypeApp)); err == nil { + t.Error("the stale package was kept") + } +} + +func TestCheckAndInstall(t *testing.T) { + e := newEnv(t) + e.s.d.Check = func(context.Context) error { return e.s.Offer(e.app(t, "2.1.0", 2)) } + out := e.s.CheckAndInstall(context.Background()) + if out.CheckError != "" || len(out.Results) != 1 || out.Results[0].Action != importer.ActionInstalled { + t.Fatalf("outcome = %+v", out) + } + if st, _ := e.l.State().Load(); st.AppVersion != "2.1.0" { + t.Fatalf("app = %s", st.AppVersion) + } +} + +func TestInfoReportsTheLastCheck(t *testing.T) { + e := newEnv(t) + if i := e.s.Info(); i.LastCheckAt != nil || i.LastCheckError != "" { + t.Fatalf("before any check: %+v", i) + } + e.s.d.Check = func(context.Context) error { return errors.New("github.com: no route to host") } + e.s.CheckAndInstall(context.Background()) + i := e.s.Info() + if i.LastCheckAt == nil || i.LastCheckError != "github.com: no route to host" { + t.Fatalf("after a failed check: %+v", i) + } + e.s.CheckDone(nil) + if i := e.s.Info(); i.LastCheckError != "" { + t.Fatalf("a good check did not clear the error: %+v", i) + } +} diff --git a/internal/updatescreen/updatescreen.go b/internal/updatescreen/updatescreen.go index 1450394..e75fbf4 100644 --- a/internal/updatescreen/updatescreen.go +++ b/internal/updatescreen/updatescreen.go @@ -1,7 +1,13 @@ // Package updatescreen puts the kiosk on the "Working on updates" screen // (update-anim/index.html, served by the local server at /_mb/updating) while -// the importer installs something a person brought to the board, and returns -// it to the board afterwards (docs/architecture.md, section 8). +// the importer installs something, shows the outcome, and returns the kiosk to +// the board afterwards (docs/architecture.md, section 8). When nothing is +// installed there is no screen: the board app shows a banner instead. +// +// Switching between the board and the screen is a dip to black: the page on +// screen fades to black (injected over CDP, so it works whatever page it is), +// and the next page fades in from black on its own. A true cross-fade would +// need both pages alive at once, which a navigation cannot give. // // The screen is driven over CDP through its window.mbUpdate hook. It lives in // the agent, not the board app, because an app update replaces the app while @@ -22,6 +28,7 @@ import ( "time" "github.com/LensBridge/agent/internal/cdp" + "github.com/LensBridge/agent/internal/notice" ) // Path is where the local server serves the screen. @@ -30,14 +37,35 @@ const Path = "/_mb/updating" // callTimeout bounds one CDP round trip. const callTimeout = 5 * time.Second +// fadeOutScript lays a black veil over whatever page is showing and resolves +// once it is opaque. Keep the duration in step with the fade-in of +// update-anim/index.html and of the board app's index.html. +const fadeOutScript = `new Promise(function (done) { + var v = document.getElementById('mb-fade-out'); + if (!v) { + v = document.createElement('div'); + v.id = 'mb-fade-out'; + v.style.cssText = 'position:fixed;inset:0;background:#000;opacity:0;z-index:2147483647;' + + 'pointer-events:none;transition:opacity 600ms ease-in-out'; + (document.body || document.documentElement).appendChild(v); + v.getBoundingClientRect(); + } + v.style.opacity = '1'; + setTimeout(function () { done(true); }, 650); +})` + // Screen implements importer.Screen. type Screen struct { - cdp *cdp.Client - baseURL string // e.g. http://127.0.0.1:8080 - logger *slog.Logger + cdp *cdp.Client + baseURL string // e.g. http://127.0.0.1:8080 + logger *slog.Logger + mu sync.Mutex returnAt *time.Timer active bool + // carry is the outcome of an agent update, held while the batch queued + // behind it installs, and shown together with that batch's outcome. + carry *notice.Notice } // New returns a Screen for the kiosk at the default CDP endpoint, whose board @@ -53,16 +81,23 @@ func (s *Screen) Active() bool { return s.active } -// Begin navigates the kiosk to the update screen and waits for its hook. +// Begin puts the kiosk on the update screen, or, when it is already there +// (held over an agent restart), takes it back to the working state. func (s *Screen) Begin(ctx context.Context) { s.mu.Lock() if s.returnAt != nil { s.returnAt.Stop() s.returnAt = nil } + already := s.active s.active = true s.mu.Unlock() + if already && s.OnScreen(ctx) { + s.eval(ctx, "window.mbUpdate && window.mbUpdate.working()") + return + } + s.fadeOut(ctx) if err := s.navigate(ctx, s.baseURL+Path); err != nil { s.logger.Debug("update screen: navigate failed", "err", err) return @@ -84,31 +119,45 @@ func (s *Screen) Begin(ctx context.Context) { } } -// Caption sets the line under the headline. +// Caption sets the line under "Working on updates". func (s *Screen) Caption(ctx context.Context, text string) { arg, _ := json.Marshal(text) s.eval(ctx, "window.mbUpdate && window.mbUpdate.caption("+string(arg)+")") } -// Finish shows the outcome, then returns the kiosk to the board when the -// countdown ends. When restarting, the agent is about to be replaced; the new -// agent returns the kiosk to the board when it starts (see Resume). -func (s *Screen) Finish(ctx context.Context, ok bool, detail string, restarting bool) { - seconds := 5 - opts := map[string]any{"seconds": seconds, "caption": detail, "message": "Returning to MusallahBoard"} - switch { - case restarting: - opts["message"] = "Restarting MusallahBoard" - case !ok: - seconds = 10 - opts["seconds"] = seconds - opts["headline"] = "Update not installed" +// Restarting says the agent is about to be replaced. The screen stays up and +// working; the next agent finishes it (package agentupdate). +func (s *Screen) Restarting(ctx context.Context) { + s.Caption(ctx, "Restarting MusallahBoard") +} + +// Finish shows the outcome, merged with any held one, then returns the kiosk +// to the board when the countdown ends. +func (s *Screen) Finish(ctx context.Context, n notice.Notice) { + s.mu.Lock() + if s.carry != nil { + n = merge(*s.carry, n) + s.carry = nil } - arg, _ := json.Marshal(opts) - s.eval(ctx, "window.mbUpdate && window.mbUpdate.complete("+string(arg)+")") - if restarting { - return + if s.returnAt != nil { + s.returnAt.Stop() + } + s.mu.Unlock() + + seconds := 6 + if n.Tone == notice.Problem { + seconds = 12 + } + lines := n.Lines + if lines == nil { + lines = []string{} } + arg, _ := json.Marshal(map[string]any{ + "tone": n.Tone, "headline": n.Headline, "lines": lines, "footer": n.Footer, + "seconds": seconds, "message": "Returning to MusallahBoard", + }) + s.eval(ctx, "window.mbUpdate && window.mbUpdate.complete("+string(arg)+")") + s.mu.Lock() defer s.mu.Unlock() s.returnAt = time.AfterFunc(time.Duration(seconds)*time.Second, func() { @@ -116,20 +165,50 @@ func (s *Screen) Finish(ctx context.Context, ok bool, detail string, restarting }) } -// Resume is called once at agent startup. If the kiosk is still on the update -// screen (the previous agent was replaced mid-update, or crashed), it -// completes it and returns to the board. -func (s *Screen) Resume(ctx context.Context) { - cctx, cancel := context.WithTimeout(ctx, callTimeout) - target, err := s.cdp.FirstPageTarget(cctx) - cancel() - if err != nil || !strings.Contains(target.URL, Path) { - return - } +// holdTimeout bounds how long a held screen waits for the batch queued behind +// an agent update, should that batch never report. +const holdTimeout = 3 * time.Minute + +// Hold keeps the screen up with n, the outcome of an agent update, for the +// batch queued behind it to finish; that batch's Finish shows both. +func (s *Screen) Hold(ctx context.Context, n notice.Notice) { s.mu.Lock() s.active = true + s.carry = &n + if s.returnAt != nil { + s.returnAt.Stop() + } + s.returnAt = time.AfterFunc(holdTimeout, func() { s.Finish(context.Background(), notice.Notice{Tone: notice.Progress}) }) s.mu.Unlock() - s.Finish(ctx, true, "", false) + s.eval(ctx, "window.mbUpdate && window.mbUpdate.working()") + s.Caption(ctx, "Installing the rest of the update") +} + +// OnScreen reports whether the kiosk is showing the update screen (it is +// after an agent update restarted the agent under it). +func (s *Screen) OnScreen(ctx context.Context) bool { + cctx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + target, err := s.cdp.FirstPageTarget(cctx) + return err == nil && strings.Contains(target.URL, Path) +} + +// merge combines a held outcome with the one that followed it: the more +// serious tone and its headline, and the lines of both. +func merge(a, b notice.Notice) notice.Notice { + rank := map[notice.Tone]int{notice.Progress: 0, notice.Neutral: 1, notice.OK: 2, notice.Problem: 3} + out := a + if rank[b.Tone] > rank[a.Tone] { + out = b + } + out.Lines = notice.Fold(append(append([]string{}, a.Lines...), b.Lines...)) + if out.Footer == "" { + out.Footer = a.Footer + if out.Footer == "" { + out.Footer = b.Footer + } + } + return out } func (s *Screen) returnToBoard(ctx context.Context) { @@ -137,11 +216,22 @@ func (s *Screen) returnToBoard(ctx context.Context) { s.active = false s.returnAt = nil s.mu.Unlock() + s.fadeOut(ctx) if err := s.navigate(ctx, s.baseURL+"/"); err != nil { s.logger.Debug("update screen: return to board failed", "err", err) } } +// fadeOut fades the current page to black. If it cannot (no kiosk, a page +// that will not run script), the switch is simply a cut. +func (s *Screen) fadeOut(ctx context.Context) { + cctx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + if err := s.cdp.EvaluateValue(cctx, fadeOutScript, nil); err != nil { + s.logger.Debug("update screen: fade out failed", "err", err) + } +} + func (s *Screen) navigate(ctx context.Context, url string) error { cctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() diff --git a/internal/uploadserver/page.go b/internal/uploadserver/page.go index fa8ab4c..611dc3b 100644 --- a/internal/uploadserver/page.go +++ b/internal/uploadserver/page.go @@ -17,13 +17,15 @@ header p{margin:6px 0 0;opacity:.8;font-size:14px}main{max-width:760px;margin:0 dt{color:var(--muted)}dd{margin:0}#drop{border:2px dashed var(--line);border-radius:10px;padding:28px;text-align:center;cursor:pointer} #drop.over{border-color:var(--navy);background:#eef3fa}button{background:var(--navy);color:#fff;border:0;border-radius:8px;padding:10px 18px;font-size:15px;cursor:pointer} button:disabled{opacity:.5;cursor:default}ul{padding-left:18px;margin:8px 0}progress{width:100%;height:10px} -.ok{color:var(--ok)}.bad{color:var(--bad)}.muted{color:var(--muted);font-size:13px} +.ok{color:var(--ok)}.bad{color:var(--bad)}.neutral{color:var(--muted)}.muted{color:var(--muted);font-size:13px} +.detail{display:block;color:var(--muted);font-size:12px}h3{margin:12px 0 4px;font-size:16px}

MusallahBoard update

Send signed update packages (.mbu) to this board.

This board

Status
loading…

Send updates

Drop .mbu files here, or click to choose
+

Up to 16 files at a time, 1 GiB in total.

    Content packages come from LensBridge (Devices, the board, Download offline bundle). Board software packages come from the MusallahBoard releases. The board checks every signature itself and refuses anything not meant for it.

    @@ -40,10 +42,14 @@ function loadStatus(){fetch('/api/status',{cache:'no-store'}).then(function(r){r var drift=Math.round(s.clock.unix-Date.now()/1000); row(dl,'Board clock',new Date(s.clock.unix*1000).toLocaleString()+(Math.abs(drift)>5?(' ('+drift+' s off this computer; corrected when you send an update)'):'')); row(dl,'RTC',s.rtc?'present':'not fitted'); + var src={ntp:'set from the internet',rtc:'kept by the hardware clock',uploader:'set from a laptop or phone',starting:'checking',unverified:'NOT CONFIRMED: send an update from this page to set it'}; + if(s.clock.source)row(dl,'Clock',src[s.clock.source]||s.clock.source); }).catch(function(){document.getElementById('st').textContent='Could not reach the board.'})} function show(){var ul=document.getElementById('files');ul.textContent='';chosen.forEach(function(f){ul.appendChild(el('li',null,f.name+' ('+Math.round(f.size/1024)+' KB)'))}); document.getElementById('send').disabled=!chosen.length} -function add(list){for(var i=0;i240000){p.className='bad';p.textContent='The agent update did not start; the board is still running agent '+s.agentVersion+'. The board shows the details on screen.';loadStatus();return} + if(sawDown&&Date.now()-start>150000){p.className='bad';p.textContent='The new agent did not start, so the board went back to agent '+s.agentVersion+'. The board shows the details on screen.';loadStatus();return} + setTimeout(poll,3000) + }).catch(function(){sawDown=true; + if(Date.now()-start>240000){p.className='bad';p.textContent='The board has not come back after 4 minutes. Check its screen, and make sure it has power.';return} + setTimeout(poll,3000)}) + })()} loadStatus(); ` diff --git a/internal/uploadserver/server.go b/internal/uploadserver/server.go index 0e0a6ba..9e760a7 100644 --- a/internal/uploadserver/server.go +++ b/internal/uploadserver/server.go @@ -28,10 +28,12 @@ import ( "strconv" "time" + "github.com/LensBridge/agent/internal/clock" "github.com/LensBridge/agent/internal/fsutil" "github.com/LensBridge/agent/internal/importer" "github.com/LensBridge/agent/internal/localserver" "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/notice" "github.com/LensBridge/agent/internal/store" ) @@ -43,7 +45,7 @@ const ServiceIP = "10.77.0.1" const ListenAddr = ServiceIP + ":80" const ( - maxParts = 8 + maxParts = 16 // as many as a USB stick (usbimport.MaxFiles) maxTotalBytes = 1 << 30 ) @@ -64,9 +66,11 @@ type Deps struct { // May be nil. ApplyClientTime func(clientUnix int64, b importer.Batch) ClockReport RTCPresent func() bool - UpdateActive func() bool - Logger *slog.Logger - Now func() time.Time + // ClockInfo says whether the board's clock can be believed. May be nil. + ClockInfo func() clock.Info + UpdateActive func() bool + Logger *slog.Logger + Now func() time.Time } // Server is the upload server. @@ -143,6 +147,7 @@ type Status struct { Clock struct { Unix int64 `json:"unix"` Timezone string `json:"timezone"` + clock.Info } `json:"clock"` RTC bool `json:"rtc"` Update localserver.UpdateInfo `json:"update"` @@ -158,6 +163,9 @@ func (s *Server) status() Status { if s.d.RTCPresent != nil { st.RTC = s.d.RTCPresent() } + if s.d.ClockInfo != nil { + st.Clock.Info = s.d.ClockInfo() + } if s.d.UpdateActive != nil { st.Update.Active = s.d.UpdateActive() } @@ -167,8 +175,16 @@ func (s *Server) status() Status { // ImportResponse is POST /api/import's body. type ImportResponse struct { Results []importer.Result `json:"results"` - Clock *ClockReport `json:"clock,omitempty"` - Message string `json:"message,omitempty"` + // Notice is what the uploader should be told about the upload. + Notice *notice.Notice `json:"notice,omitempty"` + // Restarting means a new agent was staged and is about to replace the + // one that answered; the rest of the upload installs once it runs. + // AgentVersion is the answering agent's, so the page can tell the new + // one (or a rollback) when the board is back. + Restarting bool `json:"restarting,omitempty"` + AgentVersion string `json:"agentVersion,omitempty"` + Clock *ClockReport `json:"clock,omitempty"` + Message string `json:"message,omitempty"` } var safeNameRE = regexp.MustCompile(`[^A-Za-z0-9._-]`) @@ -251,7 +267,13 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusConflict, ImportResponse{Message: "the board is already installing an update; try again in a minute"}) return } - resp := ImportResponse{Results: b.Results} + resp := ImportResponse{Results: b.Results, Notice: &b.Notice, AgentVersion: s.d.AgentVersion} + if b.AgentStaged { + // The board's own screen says "Update complete" once the new agent + // runs; the uploader, who is told now, gets the truth for now. + n := restartingNotice(b) + resp.Notice, resp.Restarting = &n, true + } if v := r.Header.Get("X-MB-Client-Time"); v != "" && s.d.ApplyClientTime != nil { if unix, err := strconv.ParseInt(v, 10, 64); err == nil { unix += int64(s.d.Now().Sub(received) / time.Second) @@ -262,6 +284,28 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// restartingNotice is what an uploader is told when the upload staged a new +// agent: nothing is finished yet, the board is about to restart, and the +// rest of the upload waits for it. +func restartingNotice(b importer.Batch) notice.Notice { + var lines []string + queued := 0 + for _, r := range b.Results { + switch r.Action { + case importer.ActionStaged: + lines = append(lines, r.Message+" is being installed") + case importer.ActionQueued: + queued++ + case importer.ActionRejected: + lines = append(lines, r.Message) + } + } + if queued > 0 { + lines = append(lines, fmt.Sprintf("%d more update(s) install once the board is back", queued)) + } + return notice.New(notice.Progress, "Restarting to finish the update", lines...) +} + // idle is a cheap pre-check; TryImport is the authoritative one. func (s *Server) idle() bool { return !s.d.Importer.Busy() } diff --git a/internal/usbimport/usbimport.go b/internal/usbimport/usbimport.go index 1b8d87f..8e97afb 100644 --- a/internal/usbimport/usbimport.go +++ b/internal/usbimport/usbimport.go @@ -9,6 +9,11 @@ // It verifies nothing itself. The daemon verifies every package exactly as it // would from any other source, and the helper just waits for its results so // they end up in the journal next to the insertion. +// +// The person at the board hears about the stick through banners: the helper +// drops a notice (Announce) in the inbox, which the daemon shows over the +// running board. "Reading USB stick" as soon as the stick is seen, then +// either the daemon's own outcome for the packages, or why there are none. package usbimport import ( @@ -25,8 +30,10 @@ import ( "strings" "time" + "github.com/LensBridge/agent/internal/fsutil" "github.com/LensBridge/agent/internal/importer" "github.com/LensBridge/agent/internal/mbu" + "github.com/LensBridge/agent/internal/notice" "github.com/LensBridge/agent/internal/store" ) @@ -92,25 +99,34 @@ func (h *Helper) Run(ctx context.Context, name string) error { if !NameRE.MatchString(name) { return fmt.Errorf("%q is not a USB disk name (want sdX or sdXN)", name) } + h.Announce(name, notice.New(notice.Progress, "Reading USB stick")) dev := filepath.Join(h.DevDir, name) fsType, err := h.Exec(ctx, "blkid", "-o", "value", "-s", "TYPE", dev) fsType = strings.TrimSpace(fsType) if err != nil || fsType == "" { h.Logf("%s: no filesystem found (%v); ignoring it", name, err) + h.Announce(name, notice.New(notice.Problem, "Can't read this USB stick", + "Format it as FAT32 or exFAT, then copy the updates onto it again")) return ErrUnsupported } mountType, ok := h.mountType(fsType) if !ok { h.Logf("%s: filesystem %q is not accepted (use FAT32, exFAT, NTFS or ext4); ignoring it", name, fsType) + h.Announce(name, notice.New(notice.Problem, "Can't read this USB stick", + "It is formatted as "+strings.ToUpper(fsType)+", which the board does not read", + "Format it as FAT32 or exFAT, then copy the updates onto it again")) return ErrUnsupported } mp := filepath.Join(h.MountRoot, name) if err := os.MkdirAll(mp, 0o700); err != nil { + h.Announce(name, notice.New(notice.Problem, "Can't read this USB stick", "The board could not open it")) return fmt.Errorf("cannot create mount point %s: %w", mp, err) } if out, err := h.Exec(ctx, "mount", "-t", mountType, "-o", MountOptions, dev, mp); err != nil { os.Remove(mp) + h.Announce(name, notice.New(notice.Problem, "Can't read this USB stick", + "The board could not open it. Try formatting it as FAT32 or exFAT")) return fmt.Errorf("could not mount %s (%s): %v %s", name, fsType, err, strings.TrimSpace(out)) } h.Logf("%s: mounted read-only (%s)", name, fsType) @@ -119,17 +135,26 @@ func (h *Helper) Run(ctx context.Context, name string) error { for _, n := range notes { h.Logf("%s: %s", name, n) } - var queued []queuedFile + var parts []part if len(files) == 0 { h.Logf("%s: no .mbu packages found (put them at the top of the stick or in a %s folder)", name, FolderName) } else { - queued = h.copyAll(name, files) + parts = h.copyAll(name, files) } - // Unmount before waiting, so the stick can be pulled as soon as the copy - // is done, whatever the daemon then makes of it. + // Unmount before the daemon sees anything, so that by the time the board + // says what it makes of the stick ("You can remove the USB stick" with + // every outcome), the stick really can be pulled. h.unmount(ctx, name, mp) + queued := h.queue(name, parts) - if len(queued) == 0 { + switch { + case len(files) == 0: + h.Announce(name, notice.New(notice.Neutral, "No updates on this USB stick", + "Put the .mbu files at the top of the stick, or in a "+FolderName+" folder")) + return nil + case len(queued) == 0: + h.Announce(name, notice.New(notice.Problem, "Can't read this USB stick", + "Copying the updates off it failed. Try copying them again, or another stick")) return nil } h.Logf("%s: copied %d package(s); waiting for the board to check and install them", name, len(queued)) @@ -163,7 +188,35 @@ func (h *Helper) unmount(ctx context.Context, name, mp string) { } } os.Remove(mp) - h.Logf("%s: unmounted; the stick can be removed once the screen says the update is complete", name) + h.Logf("%s: unmounted; the stick can be removed", name) +} + +// Announce puts n on the board as a banner, through the daemon's inbox +// (inbox/usb-.notice.json). A later notice for the same device replaces +// an earlier one the daemon has not shown yet. Best effort: a stick is read +// whether or not anyone is told. +func (h *Helper) Announce(dev string, n notice.Notice) { + // Every outcome is announced once the stick is no longer in use; only + // "Reading USB stick" is not an outcome. + if n.Tone != notice.Progress { + n.Footer = notice.RemoveStick + } + inbox := h.Layout.Inbox() + raw, err := json.Marshal(n) + if err == nil { + err = os.MkdirAll(inbox, 0o770) + } + path := filepath.Join(inbox, "usb-"+dev+importer.NoticeSuffix) + if err == nil { + err = fsutil.WriteAtomic(path, raw, 0o644) + } + if err != nil { + h.Logf("%s: could not tell the board: %v", dev, err) + return + } + if h.Chown != nil { + h.Chown(path, inbox) + } } // Candidate is a package file found on the stick. @@ -239,19 +292,23 @@ type queuedFile struct { inbox string // base name in the inbox } -// copyAll copies every candidate to a .part name first, and only renames -// them to .mbu once all are copied, so the daemon's next inbox scan sees the -// whole stick as one batch (one update screen, agent first). -func (h *Helper) copyAll(dev string, files []Candidate) []queuedFile { +// part is a package copied into the inbox under a .part name, which the +// daemon ignores until queue renames it. +type part struct { + q queuedFile + path string +} + +// copyAll copies every candidate to a .part name first; queue renames them +// to .mbu once all are copied and the stick is unmounted, so the daemon's +// next inbox scan sees the whole stick as one batch (one update screen, +// agent first). +func (h *Helper) copyAll(dev string, files []Candidate) []part { inbox := h.Layout.Inbox() if err := os.MkdirAll(inbox, 0o770); err != nil { h.Logf("%s: cannot use the inbox %s: %v", dev, inbox, err) return nil } - type part struct { - q queuedFile - path string - } var parts []part for i, c := range files { base := inboxName(dev, i+1, filepath.Base(c.Path)) @@ -269,6 +326,12 @@ func (h *Helper) copyAll(dev string, files []Candidate) []queuedFile { } parts = append(parts, part{queuedFile{orig: filepath.Base(c.Path), inbox: base}, tmp}) } + return parts +} + +// queue hands copied packages to the daemon. +func (h *Helper) queue(dev string, parts []part) []queuedFile { + inbox := h.Layout.Inbox() var out []queuedFile for _, p := range parts { if err := os.Rename(p.path, filepath.Join(inbox, p.q.inbox)); err != nil { diff --git a/internal/usbimport/usbimport_test.go b/internal/usbimport/usbimport_test.go index 50326e7..d109455 100644 --- a/internal/usbimport/usbimport_test.go +++ b/internal/usbimport/usbimport_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/LensBridge/agent/internal/importer" + "github.com/LensBridge/agent/internal/notice" "github.com/LensBridge/agent/internal/store" ) @@ -232,9 +233,50 @@ func TestRunTimesOutWithoutDaemon(t *testing.T) { t.Fatalf("log = %q", *log) } // The file was queued under a .mbu name, not left as .part. - entries, _ := os.ReadDir(h.Layout.Inbox()) - if len(entries) != 1 || entries[0].Name() != "usb-sda-1-a.mbu" { - t.Fatalf("inbox = %v", entries) + queued, _ := filepath.Glob(filepath.Join(h.Layout.Inbox(), "*.mbu")) + if len(queued) != 1 || filepath.Base(queued[0]) != "usb-sda-1-a.mbu" { + t.Fatalf("inbox = %v", queued) + } + // The board was told a stick is being read; its outcome is the daemon's. + if n := announced(t, h, "sda"); n.Headline != "Reading USB stick" { + t.Fatalf("notice = %+v", n) + } +} + +// announced is the notice the helper left for the daemon about dev. +func announced(t *testing.T, h *Helper, dev string) notice.Notice { + t.Helper() + raw, err := os.ReadFile(filepath.Join(h.Layout.Inbox(), "usb-"+dev+importer.NoticeSuffix)) + if err != nil { + t.Fatalf("no notice for %s: %v", dev, err) + } + var n notice.Notice + if err := json.Unmarshal(raw, &n); err != nil { + t.Fatal(err) + } + return n +} + +func TestRunTellsTheBoard(t *testing.T) { + cases := []struct { + name, fs string + stick map[string]int + tone notice.Tone + headline string + }{ + {"empty stick", "vfat", map[string]int{}, notice.Neutral, "No updates on this USB stick"}, + {"unknown filesystem", "apfs", map[string]int{}, notice.Problem, "Can't read this USB stick"}, + {"no filesystem", "", map[string]int{}, notice.Problem, "Can't read this USB stick"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := &fakeSys{fs: tc.fs, stick: tc.stick} + h, _ := newHelper(t, f) + _ = h.Run(context.Background(), "sdb1") + if n := announced(t, h, "sdb1"); n.Tone != tc.tone || n.Headline != tc.headline || len(n.Lines) == 0 { + t.Fatalf("notice = %+v", n) + } + }) } } diff --git a/internal/wsclient/frames.go b/internal/wsclient/frames.go index 218b429..c97a647 100644 --- a/internal/wsclient/frames.go +++ b/internal/wsclient/frames.go @@ -75,6 +75,10 @@ type Telemetry struct { // not an id. The backend typed it as a UUID once; every heartbeat from a // working board failed to parse and the session was closed as a bad frame. DisplayedFrameKey string `json:"displayedFrameKey,omitempty"` + + // Board is what the board runs and shows (localserver.BoardReport): the + // admin portal's view of a board it cannot see. + Board any `json:"board,omitempty"` } // CommandAckFrame, CommandProgressFrame, CommandResultFrame are the three diff --git a/internal/wsclient/wsclient.go b/internal/wsclient/wsclient.go index 96e061d..36eb513 100644 --- a/internal/wsclient/wsclient.go +++ b/internal/wsclient/wsclient.go @@ -20,6 +20,7 @@ import ( "log/slog" "math/rand" "net/http" + "sync" "time" "github.com/coder/websocket" @@ -33,7 +34,7 @@ const ( dialTimeout = 15 * time.Second authTimeout = 10 * time.Second writeTimeout = 10 * time.Second - maxFrameBytes = 20 << 20 // 20 MiB + maxFrameBytes = 20 << 20 // 20 MiB minBackoff = time.Second maxBackoff = 5 * time.Minute @@ -69,8 +70,40 @@ type Client struct { agentVersion string safeMode bool - onCommand CommandHandler - prober telemetry.PageProber + onCommand CommandHandler + prober telemetry.PageProber + boardReport func() any + + stateMu sync.Mutex + state ConnState +} + +// ConnState is the backend connection as `status` reports it. +type ConnState struct { + Connected bool `json:"connected"` + // Since is when the connection last came up or went down (RFC 3339). + Since string `json:"since,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +// State reports the backend connection. +func (c *Client) State() ConnState { + c.stateMu.Lock() + defer c.stateMu.Unlock() + return c.state +} + +func (c *Client) setState(connected bool, err error) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + if c.state.Connected != connected || c.state.Since == "" { + c.state.Since = time.Now().UTC().Format(time.RFC3339) + } + c.state.Connected = connected + c.state.LastError = "" + if err != nil { + c.state.LastError = err.Error() + } } func New(cfg *config.Config, priv ed25519.PrivateKey, logger *slog.Logger, agentVersion string, safeMode bool) *Client { @@ -92,6 +125,10 @@ func (c *Client) SetCommandHandler(h CommandHandler) { c.onCommand = h } // systemd unit state. Must be called before Run. func (c *Client) SetPageProber(p telemetry.PageProber) { c.prober = p } +// SetBoardReport supplies the board's own report for every heartbeat +// (localserver.BoardReport). Optional. Must be called before Run. +func (c *Client) SetBoardReport(f func() any) { c.boardReport = f } + // Run blocks until ctx is cancelled, repeatedly attempting to maintain a live // authenticated session. Errors are logged and trigger a backoff-and-retry. func (c *Client) Run(ctx context.Context) { @@ -102,6 +139,7 @@ func (c *Client) Run(ctx context.Context) { } authed, err := c.runOnce(ctx) + c.setState(false, err) switch { case ctx.Err() != nil: return @@ -190,6 +228,7 @@ func (c *Client) runOnce(ctx context.Context) (authed bool, err error) { "heartbeatInterval", hbInterval, ) + c.setState(true, nil) return true, c.serve(ctx, sess, hbInterval) } @@ -246,6 +285,9 @@ func (c *Client) heartbeatLoop(ctx context.Context, sess *session, interval time SessionID: sess.sessionID, Telemetry: TelemetryFromSnapshot(snap), } + if c.boardReport != nil { + hb.Telemetry.Board = c.boardReport() + } wctx, cancel := context.WithTimeout(ctx, writeTimeout) defer cancel() return sess.sendJSON(wctx, hb) diff --git a/packaging/embed.go b/packaging/embed.go new file mode 100644 index 0000000..726292d --- /dev/null +++ b/packaging/embed.go @@ -0,0 +1,18 @@ +// Package packaging embeds the files the agent installs itself, so a board +// set up by the curl|bash setup.sh (which has no repository checkout) gets the +// same bytes as one installed from this directory. +package packaging + +import _ "embed" + +// WaitingHTML is waiting.html: the kiosk's "waiting for enrollment" splash, +// installed by `musallahboard-agent splash install`. +// +//go:embed waiting.html +var WaitingHTML []byte + +// StartingHTML is starting.html: the kiosk's first page on an enrolled board, +// which waits for the agent to answer before moving to the board. +// +//go:embed starting.html +var StartingHTML []byte diff --git a/packaging/install.sh b/packaging/install.sh index 3ccdfd5..4d16504 100644 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -380,6 +380,7 @@ BASHRC install -o root -g root -m 0755 "$KIOSK_LAUNCHER_SRC" "$KIOSK_LAUNCHER_DEST" mkdir -p "$KIOSK_SHARE_DIR" install -o root -g root -m 0644 "$KIOSK_SPLASH_SRC" "$KIOSK_SHARE_DIR/waiting.html" + install -o root -g root -m 0644 "${AGENT_DIR}/packaging/starting.html" "$KIOSK_SHARE_DIR/starting.html" # Copied verbatim — no substitution. The unit names $KIOSK_USER directly, # which is the whole point of fixing the account name: a .deb ships static diff --git a/packaging/musallahboard-agent.service b/packaging/musallahboard-agent.service index d67e70d..a1384d2 100644 --- a/packaging/musallahboard-agent.service +++ b/packaging/musallahboard-agent.service @@ -3,12 +3,12 @@ Description=MusallahBoard device agent Documentation=https://github.com/LensBridge/agent After=network-online.target Wants=network-online.target -# Crash-loop guard: if the agent fails 5 times in 60s, stop trying. These are -# [Unit] settings; systemd ignores StartLimitIntervalSec= under [Service]. -# A bad self-update is caught earlier than this by the updater's health check -# and rollback (musallahboard-agent-update.service). -StartLimitBurst=5 -StartLimitIntervalSec=60 +# Never give up on the agent: without it the screen has nothing to show, and a +# board nobody is standing at cannot be restarted by hand. A crash loop backs +# off instead (RestartSteps below) so it costs little while it lasts. A bad +# self-update is caught earlier by the updater's health check and rollback +# (musallahboard-agent-update.service). +StartLimitIntervalSec=0 [Service] Type=notify @@ -16,7 +16,12 @@ User=musallahdaemon Group=musallahdaemon ExecStart=/usr/bin/musallahboard-agent run Restart=always +# 5 s after the first failure, growing to 2 minutes over six restarts. +# RestartSteps/RestartMaxDelaySec need systemd 254 (trixie); older systemd +# ignores them with a warning and restarts every 5 s. RestartSec=5 +RestartSteps=6 +RestartMaxDelaySec=120 # sd_notify watchdog: agent must call WATCHDOG=1 every 30s or systemd # kills it. Catches goroutine deadlocks where the process is alive but diff --git a/packaging/start-kiosk.sh b/packaging/start-kiosk.sh index babb85b..a8803d3 100644 --- a/packaging/start-kiosk.sh +++ b/packaging/start-kiosk.sh @@ -24,11 +24,20 @@ set -euo pipefail KIOSK_URL_FILE="/etc/musallahboard/kiosk-url" SPLASH="file:///usr/share/musallahboard/waiting.html" +STARTING="file:///usr/share/musallahboard/starting.html" + URL="" if [[ -r "$KIOSK_URL_FILE" ]]; then URL="$(tr -d '[:space:]' < "$KIOSK_URL_FILE")" fi -[[ -z "$URL" ]] && URL="$SPLASH" +if [[ -z "$URL" ]]; then + URL="$SPLASH" +elif [[ -r "${STARTING#file://}" ]]; then + # Open the board through the local "starting" page, which waits for the + # agent to answer: loading the agent's URL while it is down would leave + # Chromium on its own "refused to connect" page. + URL="${STARTING}#${URL}" +fi # Resolve a real (non-snap) browser. A snap shim is a tiny shell script that # execs /snap/bin or BAMF_DESKTOP_FILE_HINT; a real browser is an ELF. Treat diff --git a/packaging/starting.html b/packaging/starting.html new file mode 100644 index 0000000..6028357 --- /dev/null +++ b/packaging/starting.html @@ -0,0 +1,58 @@ + + + + +MusallahBoard + + + + +
    +
    + +

    MusallahBoard is starting

    +

    +
    +
    + + + diff --git a/packaging/waiting.html b/packaging/waiting.html index c7d9d84..eb45e38 100644 --- a/packaging/waiting.html +++ b/packaging/waiting.html @@ -4,27 +4,34 @@ MusallahBoard @@ -36,9 +43,13 @@
    + -
    MusallahBoard v2.0  ·  Powered by IbraSoft
    +
    MusallahBoard
    @@ -66,10 +77,12 @@ label.textContent = ips.length > 1 ? 'Reachable at' : 'This device is at'; value.textContent = ips.join(' · '); value.classList.remove('offline'); + document.getElementById('net-next').hidden = false; } else { label.textContent = 'Network'; value.textContent = 'Not connected'; value.classList.add('offline'); + document.getElementById('net-next').hidden = true; } // Hostname and SSID are secondary: useful for telling two boards on a @@ -79,6 +92,12 @@ if (text(info.ssid)) bits.push('Wi-Fi: ' + text(info.ssid)); meta.textContent = bits.join(' · '); + // The version is the running agent's, pushed like the address, so it + // is right after a self-update without this file being rewritten. + if (text(info.agentVersion)) { + document.getElementById('footer').textContent = 'MusallahBoard · agent ' + text(info.agentVersion); + } + card.hidden = false; }; })(); diff --git a/setup.sh b/setup.sh index 7fc4513..ff5a21e 100755 --- a/setup.sh +++ b/setup.sh @@ -70,6 +70,8 @@ SERVICE_USER=musallahdaemon # Paths BINARY_DEST=/usr/bin/musallahboard-agent +# Where this script is published (for the instructions it prints). +SETUP_URL=https://raw.githubusercontent.com/lensbridge/agent/main/setup.sh CONFIG_DIR=/etc/musallahboard UNIT_DIR=/lib/systemd/system SUDOERS_DEST=/etc/sudoers.d/musallahboard-agent @@ -141,6 +143,12 @@ fi # ── Helper: read one answer ─────────────────────────────────────────────────── # $1 variable to set $2 prompt text $3 default +# _interactive: can we ask the person running this? Under `curl ... | bash` +# stdin is the script itself, so questions go to the terminal, /dev/tty. +_interactive() { + [[ "$MB_ASSUME_YES" != "1" ]] && { : < /dev/tty; } 2>/dev/null +} + _ask() { local _var="$1" _prompt="$2" _default="$3" _preset _reply _preset="${!_var-}" @@ -150,22 +158,25 @@ _ask() { return 0 fi - if [[ "$MB_ASSUME_YES" == "1" ]] || [[ ! -t 0 ]]; then + if ! _interactive; then printf -v "$_var" '%s' "$_default" printf ' %-34s %s (default)\n' "$_prompt" "$_default" return 0 fi - read -rp "$(printf ' %-34s [%s]: ' "$_prompt" "$_default")" _reply + if [[ -n "$_default" ]]; then + read -rp "$(printf ' %-34s [%s]: ' "$_prompt" "$_default")" _reply < /dev/tty + else + read -rp "$(printf ' %-34s: ' "$_prompt")" _reply < /dev/tty + fi printf -v "$_var" '%s' "${_reply:-$_default}" return 0 } _confirm() { local _prompt="$1" _reply - [[ "$MB_ASSUME_YES" == "1" ]] && return 0 - [[ ! -t 0 ]] && return 0 - read -rp "$_prompt" -n 1 _reply; echo + _interactive || return 0 + read -rp "$_prompt" -n 1 _reply < /dev/tty; echo [[ $_reply =~ ^[Yy]$ ]] } @@ -207,9 +218,14 @@ prompt_config() { TIMEZONE="${MB_TIMEZONE-}" _ask HOSTNAME "Hostname for this board" "musallahboard" - _ask ADMIN_USER "Admin username (SSH/sudo)" "ibra" + _ask ADMIN_USER "Admin username (SSH/sudo)" "" _ask TIMEZONE "Timezone" "America/Toronto" + [[ -n "$ADMIN_USER" ]] || \ + error "An admin username is required (the account you will SSH in as). Set MB_ADMIN_USER when not running interactively." + [[ "$ADMIN_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || \ + error "'$ADMIN_USER' is not a valid Linux username (lowercase letters, digits, - and _)." + # Validate admin user is not a reserved name. for _reserved in musallahdaemon "$KIOSK_USER"; do [[ "$ADMIN_USER" == "$_reserved" ]] && \ @@ -220,13 +236,15 @@ prompt_config() { SSH_PUB_KEY="${MB_ADMIN_SSH_KEY-}" if [[ -n "$SSH_PUB_KEY" ]]; then info "SSH key supplied for $ADMIN_USER" - elif [[ -t 0 ]]; then - echo "Paste the SSH public key for $ADMIN_USER:" - read -rp "> " SSH_PUB_KEY - [[ -z "$SSH_PUB_KEY" ]] && error "SSH public key is required (headless Pi)." + elif _interactive; then + echo "Paste the SSH public key for $ADMIN_USER (one line, starting ssh-ed25519 or ssh-rsa):" + read -rp "> " SSH_PUB_KEY < /dev/tty + [[ -z "$SSH_PUB_KEY" ]] && error "An SSH public key is required: the board has no password login." else - error "SSH public key is required. Pass MB_ADMIN_SSH_KEY." + error "An SSH public key is required: the board has no password login. Set MB_ADMIN_SSH_KEY." fi + [[ "$SSH_PUB_KEY" =~ ^(ssh-|ecdsa-|sk-) ]] || \ + error "That does not look like an SSH public key (it should start with ssh-ed25519, ssh-rsa or ecdsa-)." echo info "Summary" @@ -574,8 +592,7 @@ Description=MusallahBoard device agent Documentation=https://github.com/LensBridge/agent After=network-online.target Wants=network-online.target -StartLimitBurst=5 -StartLimitIntervalSec=60 +StartLimitIntervalSec=0 [Service] Type=notify @@ -584,6 +601,8 @@ Group=musallahdaemon ExecStart=/usr/bin/musallahboard-agent run Restart=always RestartSec=5 +RestartSteps=6 +RestartMaxDelaySec=120 WatchdogSec=60s AmbientCapabilities=CAP_SYS_TIME CAP_NET_BIND_SERVICE ProtectSystem=strict @@ -817,12 +836,19 @@ set -euo pipefail KIOSK_URL_FILE="/etc/musallahboard/kiosk-url" SPLASH="file:///usr/share/musallahboard/waiting.html" +STARTING="file:///usr/share/musallahboard/starting.html" URL="" if [[ -r "$KIOSK_URL_FILE" ]]; then URL="$(tr -d '[:space:]' < "$KIOSK_URL_FILE")" fi -[[ -z "$URL" ]] && URL="$SPLASH" +if [[ -z "$URL" ]]; then + URL="$SPLASH" +elif [[ -r "${STARTING#file://}" ]]; then + # Through the local "starting" page, which waits for the agent to answer + # instead of leaving Chromium on its "refused to connect" page. + URL="${STARTING}#${URL}" +fi pick_browser() { local cand @@ -880,103 +906,10 @@ LAUNCHER # ── Install splash page ─────────────────────────────────────────────────────── install_splash_page() { section "Splash page" - - sudo mkdir -p "$KIOSK_SHARE_DIR" - - # KEEP IN SYNC with packaging/waiting.html. This script is curl|bash'd and - # never has a repo checkout to copy from, so the page is inlined here; the - # deb/install.sh path installs the packaged file instead. The setNetInfo - # hook below is a contract with internal/splash — changing its name breaks - # the IP readout on unenrolled boards. - sudo tee "$KIOSK_SHARE_DIR/waiting.html" > /dev/null << 'HTML' - - - - -MusallahBoard - - - -
    Waiting for device enrollment...
    -
    - This screen clears automatically once the agent is enrolled. - - - -
    MusallahBoard v2.0
    - - - - -HTML - - sudo chmod 0644 "$KIOSK_SHARE_DIR/waiting.html" + # The page is embedded in the agent binary (packaging/waiting.html), so a + # curl|bash setup gets the same bytes as a packaged install, and a + # self-update refreshes it. The binary is installed before this runs. + sudo "$BINARY_DEST" splash install "$KIOSK_SHARE_DIR/waiting.html" info "Installed $KIOSK_SHARE_DIR/waiting.html" } @@ -1038,10 +971,13 @@ print_summary() { Until then the kiosk shows the local "waiting" splash, which prints this device's IP address on screen once it has one - that is the to SSH to. On enrollment the kiosk switches to the board served by the agent, which - starts syncing content and fetches the board app right away. + starts syncing content and downloads the board app within a few minutes. - A board that will not have internet: after enrolling, while it still has - internet, run bash setup.sh --service-port + A board that will not have internet: after enrolling, turn on the ethernet + service port (laptop and phone updates at http://10.77.0.1/): + sudo musallahboard-agent service-port on + To fit a DS3231 clock module as well, re-run this setup with --rtc: + curl -fsSL $SETUP_URL | bash -s -- --service-port --rtc * Boots multi-user -> musallahboard-kiosk.service -> cage -> browser * SSH as $ADMIN_USER to manage the system @@ -1158,7 +1094,7 @@ service_port_profiles() { elif existing="$(_saved_wired_profile)"; then info "Keeping your saved wired profile '$existing' as eth0's first choice" warn "It keeps NetworkManager's default of 4 attempts, so a laptop may wait a few minutes" - warn "for an address. Delete it to use $LAN_CONN instead, then re-run setup.sh --service-port." + warn "for an address. Delete it to use $LAN_CONN instead, then re-run this setup." else sudo nmcli connection add type ethernet con-name "$LAN_CONN" "${lan_props[@]}" info "Created NetworkManager connection $LAN_CONN (DHCP client, tried first)" @@ -1246,6 +1182,22 @@ EOF warn "for example on the next upload from a laptop (mbpush or http://10.77.0.1/)." } +# Part of the normal setup: everything the service port needs, left OFF, so +# `sudo musallahboard-agent service-port on` works later without this script +# (which a curl | bash install does not leave on the board). The service-port +# profile does not autoconnect until it is switched on, so eth0 behaves as +# before: a DHCP client on whatever network it is plugged into. +prepare_service_port() { + if ! command -v nmcli >/dev/null; then + warn "nmcli not found: the ethernet service port needs NetworkManager; skipping it" + return + fi + service_port_install_packages + service_port_profiles + service_port_firewall + info "Service port prepared and OFF. Turn it on with: sudo musallahboard-agent service-port on" +} + service_port_switch_on() { section "Service port: switch on" # If eth0 is on a network right now, `service-port on` leaves the port @@ -1314,6 +1266,7 @@ main() { setup_journal setup_unattended_upgrades setup_ufw + prepare_service_port setup_pi_extras install_agent_binary diff --git a/update-anim/index.html b/update-anim/index.html index 16a7194..f639ce1 100644 --- a/update-anim/index.html +++ b/update-anim/index.html @@ -308,7 +308,7 @@ height: 1.5em; line-height: 1.5; max-width: 62ch; - font-size: clamp(13px, 1.75vmin, 26px); + font-size: clamp(16px, 2.6vmin, 40px); font-weight: 400; letter-spacing: 0.01em; color: rgba(255, 255, 255, 0.72); @@ -321,9 +321,41 @@ few hundred ms reads as one line updating rather than as flicker. */ .caption.is-fading { opacity: 0; } +/* ---- Outcome lines ------------------------------------------------------ + What the update did, under the done headline: up to three short sentences + from the agent (internal/notice), "Board app 2.1.0", "The board already + has newer content". Unlike the working caption these are the last thing + the screen says, read once and from across a room, so they wrap instead of + truncating: the whole sentence matters more than a layout that never + moves, and the done state only arrives once. */ +.lines { + margin: 2.2vmin 0 0; + padding: 0; + list-style: none; + max-width: 62ch; + font-size: clamp(16px, 2.6vmin, 40px); + font-weight: 400; + line-height: 1.45; + letter-spacing: 0.01em; + color: rgba(255, 255, 255, 0.8); + text-wrap: balance; +} +.lines li + li { margin-top: 0.7vmin; } +.lines:empty { display: none; } +/* A standing instruction under the outcome ("You can remove the USB + stick"): the accent's colour, because it is the one line asking someone to + do something. */ +.footer-line { + margin: 2.2vmin 0 0; + font-size: clamp(16px, 2.6vmin, 40px); + font-weight: 500; + color: var(--gold); +} +.footer-line:empty { display: none; } + .warn { margin: 2.2vmin 0 0; - font-size: clamp(10px, 1.3vmin, 19px); + font-size: clamp(12px, 1.8vmin, 27px); font-weight: 500; letter-spacing: 0.3em; text-transform: uppercase; @@ -365,6 +397,24 @@ 0 2px 4px rgba(0, 0, 0, 0.35); } +/* A problem gets the smiley's box, glow and gold, but a ring with a mark + instead of a face: clear from across the room, and not alarming in a + prayer space. Drawn, like the throbber, so no font can fail it. */ +.alert { + display: none; + width: clamp(110px, 16vmin, 230px); + height: clamp(110px, 16vmin, 230px); + overflow: visible; + filter: + drop-shadow(0 0 10px rgba(248, 225, 93, 0.45)) + drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45)); +} +.alert .ring { fill: none; stroke: var(--gold); stroke-width: 4; } +.alert .bar { stroke: var(--gold); stroke-width: 9; stroke-linecap: round; } +.alert .dot { fill: var(--gold); } +.screen[data-tone="problem"] .smiley { display: none; } +.screen[data-tone="problem"] .alert { display: block; } + /* ---- Reduced motion ----------------------------------------------------- The ground stops, but the arc does not: a throbber that has stopped turning is the one thing on this screen that would read as a hung update, and it is @@ -382,10 +432,27 @@ .state--done { transform: none; } .screen.is-complete .state--working { transform: none; } } +/* ---- Fade in ----------------------------------------------------------- + The agent fades the board to black before it navigates here, and fades + this page to black before it goes back (internal/updatescreen). This veil + finishes the dip: the screen arrives from black instead of cutting in. */ +.veil { + position: fixed; + inset: 0; + z-index: 100; + background: #000; + pointer-events: none; + animation: veil-out 700ms ease-out 50ms both; +} +@keyframes veil-out { + from { opacity: 1; } + to { opacity: 0; visibility: hidden; } +} +
    @@ -402,9 +469,15 @@

    Working on updates

    +

    Update complete

    -

    -

    Restarting MusallahBoard in 5 seconds

    +
      + +

      Returning to MusallahBoard in 6 seconds

      @@ -422,8 +495,15 @@

      Update complete

      mbUpdate.complete() → ":) Update complete", counting 5 → 0 mbUpdate.complete(10) → the same, from ten - mbUpdate.complete(0) → no countdown, straight to "restarting now" - mbUpdate.complete({ seconds: 5, headline: "…", message: "…", caption: "…" }) + mbUpdate.complete(0) → no countdown, straight to "… now" + mbUpdate.complete({ tone: "ok" | "neutral" | "problem", headline: "…", + lines: ["Board app 2.1.0", "…"], footer: "…", seconds: 6, + message: "Returning to MusallahBoard" }) + → tone "problem" swaps the smiley for a ring + and mark; lines (at most three, the agent + folds the rest) wrap under the headline; + footer ("You can remove the USB stick") + sits under them in gold mbUpdate.working() → back to the throbber mbUpdate.state → "working" | "complete" @@ -431,10 +511,9 @@

      Update complete

      stacking a second interval on the first, which matters when the daemon retries an evaluate it never got the reply to. - caption() writes to both states at once, so the line cannot disagree with - itself across the handover and the daemon never has to care which state is - up. complete() clears it unless given one, on the grounds that "Installing - chromium-browser" under "Update complete" is a lie. + caption() belongs to the working state. complete() clears it, on the + grounds that "Installing board app 2.1.0" is stale once the update is done, + and the done state says what happened in its own lines instead. caption() returns the new text immediately, but the DOM catches up at the bottom of a 140ms fade — read the return value, not the element, if the @@ -446,6 +525,8 @@

      Update complete

      window.mbUpdate = (function () { var screenEl = document.querySelector('.screen'); var title = document.querySelector('.state--done .headline'); + var linesEl = document.querySelector('.state--done .lines'); + var footerEl = document.querySelector('.state--done .footer-line'); var line = document.querySelector('.state--done .warn'); var captions = [].slice.call(document.querySelectorAll('.caption')); var ticker = null; @@ -486,11 +567,19 @@

      Update complete

      if (typeof opts === 'number') { opts = { seconds: opts }; } opts = opts || {}; - var message = opts.message || 'Restarting MusallahBoard'; + var message = opts.message || 'Returning to MusallahBoard'; var left = opts.seconds == null ? 5 : Math.max(0, Math.floor(opts.seconds)); - if (opts.headline) { title.textContent = opts.headline; } - caption(opts.caption == null ? '' : opts.caption); + title.textContent = opts.headline || 'Update complete'; + screenEl.dataset.tone = opts.tone || 'ok'; + linesEl.textContent = ''; + (opts.lines || []).slice(0, 3).forEach(function (text) { + var li = document.createElement('li'); + li.textContent = String(text); + linesEl.appendChild(li); + }); + footerEl.textContent = opts.footer || ''; + caption(''); clearInterval(ticker); ticker = null;