Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your backend instance>\
--token <one-time-token-from-admin-ui
sudo musallahboard-agent enroll --token=<token> --backend=<backend URL>
# 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!
Expand Down Expand Up @@ -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:

Expand All @@ -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 <file.mbu>...`. `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 <file.mbu>...`.
3. **Android app.** Downloads the packages while it has signal, then uploads them over a USB ethernet adapter.

Expand Down
64 changes: 57 additions & 7 deletions cmd/agent/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -50,30 +55,50 @@ 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)
if keyErr != nil {
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 {
Expand All @@ -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 {
Expand All @@ -112,18 +148,26 @@ 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)
}

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
}
Expand All @@ -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,
})
Expand All @@ -154,21 +199,26 @@ 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})
registry.Register(&commands.ConfigRefresh{CDP: cdpClient, Before: syncer.Trigger})
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
Expand Down
21 changes: 18 additions & 3 deletions cmd/agent/import_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,33 @@ 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.")
os.Exit(1)
}
}

// 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()
Expand Down
38 changes: 33 additions & 5 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
Expand Down Expand Up @@ -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":
Expand All @@ -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":
Expand All @@ -85,17 +90,20 @@ Usage:

Administration (need sudo):
musallahboard-agent import <file.mbu>... | - 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 <device> 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)
`)
}

Expand All @@ -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=<token> --backend=<url>")
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() {
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions cmd/agent/plainlog.go
Original file line number Diff line number Diff line change
@@ -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 }
Loading
Loading