diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 058659aa..a6ebad11 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -16,6 +16,7 @@ import ( "syscall" "time" + "github.com/TeoSlayer/pilotprotocol/internal/motd" "github.com/TeoSlayer/pilotprotocol/pkg/daemon" "github.com/pilot-protocol/common/config" "github.com/pilot-protocol/common/driver" @@ -95,12 +96,17 @@ func main() { showVersion := flag.Bool("version", false, "print version and exit") logLevel := flag.String("log-level", "info", "log level (debug, info, warn, error)") logFormat := flag.String("log-format", "text", "log format (text, json)") + motdFeedURL := flag.String("motd-feed-url", motd.DefaultFeedURL, "message-of-the-day feed URL (empty to disable); overridden by $PILOT_MOTD_URL") + motdInterval := flag.Duration("motd-interval", 0, "message-of-the-day poll interval (default 15m)") flag.Parse() if *adminToken == "" { if v := os.Getenv("PILOT_ADMIN_TOKEN"); v != "" { *adminToken = v } } + if v := os.Getenv("PILOT_MOTD_URL"); v != "" { + *motdFeedURL = v + } if *showVersion { fmt.Println(version) @@ -199,6 +205,8 @@ func main() { TransportMode: *transportMode, CompatBeaconURL: *compatBeacon, CompatTLSTrust: *tlsTrust, + MOTDFeedURL: *motdFeedURL, + MOTDInterval: *motdInterval, }) // L11 plugin lifecycle (T7.1): composition root owns the diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index 8e4c895e..6726e298 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -105,6 +105,9 @@ func featureEnabled(name string) bool { func output(data interface{}) { if jsonOutput { envelope := map[string]interface{}{"status": "ok", "data": data} + if importantUpdate != "" { + envelope["important_update"] = importantUpdate + } b, _ := json.Marshal(envelope) fmt.Println(string(b)) } else { @@ -128,12 +131,16 @@ func outputOK(fields map[string]interface{}) { func fatalCode(code string, format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) if jsonOutput { - b, _ := json.Marshal(map[string]string{ + env := map[string]string{ "status": "error", "code": code, "message": msg, "error": msg, - }) + } + if importantUpdate != "" { + env["important_update"] = importantUpdate + } + b, _ := json.Marshal(env) fmt.Fprintln(os.Stderr, string(b)) } else { fmt.Fprintf(os.Stderr, "error: %s\n", msg) @@ -171,13 +178,17 @@ func classifyDaemonError(err error) string { func fatalHint(code, hint, format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) if jsonOutput { - b, _ := json.Marshal(map[string]string{ + env := map[string]string{ "status": "error", "code": code, "message": msg, "error": msg, "hint": hint, - }) + } + if importantUpdate != "" { + env["important_update"] = importantUpdate + } + b, _ := json.Marshal(env) fmt.Fprintln(os.Stderr, string(b)) } else { fmt.Fprintf(os.Stderr, "error: %s\nhint: %s\n", msg, hint) @@ -868,6 +879,8 @@ Flags: --no-encrypt disable tunnel encryption --foreground run in foreground (no fork; for systemd / shell wrappers) --wait how long to wait for daemon to become ready (default: 15s) + --motd-feed-url message-of-the-day feed (empty to disable; env PILOT_MOTD_URL) + --motd-interval message-of-the-day poll interval (default: 15m) `, "daemon stop": `Usage: pilotctl daemon stop @@ -1268,6 +1281,7 @@ Companion binaries: func main() { loadFeatureFlags() + loadMOTD() // Extract global flags before subcommand var args []string @@ -1282,6 +1296,12 @@ func main() { } } + // Prepend the message-of-the-day banner (if any) ahead of every + // command's output. No-op in --json mode, where the message instead + // rides in each envelope's important_update field. Pure local read of + // ~/.pilot/motd.json — no network, no daemon call. + printMOTDBanner() + if len(args) < 1 { usage() } diff --git a/cmd/pilotctl/motd.go b/cmd/pilotctl/motd.go new file mode 100644 index 00000000..092804fc --- /dev/null +++ b/cmd/pilotctl/motd.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/TeoSlayer/pilotprotocol/internal/motd" +) + +// importantUpdate is the message-of-the-day text active for the current UTC +// day, or "" if none. Loaded once at process start by loadMOTD() from the +// local mirror the daemon maintains — pilotctl never touches the network or +// the daemon for this, so every command stays fast. +var importantUpdate string + +// motdMirrorPath is the local file the daemon writes and pilotctl reads. It +// lives in the same ~/.pilot directory pilotctl already uses for config. +func motdMirrorPath() string { + return filepath.Join(configDir(), "motd.json") +} + +// loadMOTD reads the active message-of-the-day from the local mirror. Called +// once near the top of main(). Absent/empty/stale mirrors leave +// importantUpdate empty (no banner). +func loadMOTD() { + if text, ok := motd.ReadActiveMirror(motdMirrorPath(), time.Now()); ok { + importantUpdate = text + } +} + +// printMOTDBanner writes the human-readable banner to stdout ahead of a +// command's normal output. It is a no-op in JSON mode (there the message +// rides in the envelope's important_update field instead) and when there is +// no active message. +func printMOTDBanner() { + if jsonOutput || importantUpdate == "" { + return + } + fmt.Printf("Message of the day: %s\n\n", importantUpdate) +} diff --git a/cmd/pilotctl/zz_motd_test.go b/cmd/pilotctl/zz_motd_test.go new file mode 100644 index 00000000..f043f835 --- /dev/null +++ b/cmd/pilotctl/zz_motd_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/TeoSlayer/pilotprotocol/internal/motd" +) + +// withMOTD saves and restores the package-global banner/json state so these +// tests don't leak into the rest of the (non-parallel) package suite. +func withMOTD(t *testing.T, msg string, asJSON bool, fn func()) { + t.Helper() + origMsg, origJSON := importantUpdate, jsonOutput + importantUpdate, jsonOutput = msg, asJSON + defer func() { importantUpdate, jsonOutput = origMsg, origJSON }() + fn() +} + +func TestLoadMOTDFromMirror(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // configDir() resolves to $HOME/.pilot — write the mirror the daemon + // would have produced for today, then confirm loadMOTD picks it up. + now := time.Now() + if err := motd.WriteMirror(motdMirrorPath(), motd.Message{Date: motd.DayKey(now), Text: "overlay maintenance 22:00 UTC"}, now); err != nil { + t.Fatalf("seed mirror: %v", err) + } + + importantUpdate = "" + loadMOTD() + if importantUpdate != "overlay maintenance 22:00 UTC" { + t.Fatalf("importantUpdate = %q, want the seeded message", importantUpdate) + } + + // Clear it (empty motd) — loadMOTD must yield no banner. + if err := motd.WriteMirror(motdMirrorPath(), motd.Message{}, now); err != nil { + t.Fatalf("clear mirror: %v", err) + } + importantUpdate = "" + loadMOTD() + if importantUpdate != "" { + t.Fatalf("importantUpdate = %q, want empty after clear", importantUpdate) + } +} + +func TestPrintMOTDBannerTextMode(t *testing.T) { + withMOTD(t, "scheduled maintenance", false, func() { + out := captureStdout(t, printMOTDBanner) + if !strings.Contains(out, "Message of the day: scheduled maintenance") { + t.Fatalf("banner missing from output: %q", out) + } + }) +} + +func TestPrintMOTDBannerSuppressedInJSON(t *testing.T) { + withMOTD(t, "scheduled maintenance", true, func() { + out := captureStdout(t, printMOTDBanner) + if out != "" { + t.Fatalf("expected no text banner in JSON mode, got %q", out) + } + }) +} + +func TestPrintMOTDBannerEmpty(t *testing.T) { + withMOTD(t, "", false, func() { + out := captureStdout(t, printMOTDBanner) + if out != "" { + t.Fatalf("expected no banner with no message, got %q", out) + } + }) +} + +func TestOutputJSONCarriesImportantUpdate(t *testing.T) { + withMOTD(t, "read this first", true, func() { + out := captureStdout(t, func() { output(map[string]interface{}{"ok": true}) }) + var env map[string]interface{} + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if env["important_update"] != "read this first" { + t.Fatalf("important_update = %v, want the message", env["important_update"]) + } + }) +} + +func TestOutputJSONOmitsImportantUpdateWhenEmpty(t *testing.T) { + withMOTD(t, "", true, func() { + out := captureStdout(t, func() { output(map[string]interface{}{"ok": true}) }) + var env map[string]interface{} + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("unmarshal %q: %v", out, err) + } + if _, present := env["important_update"]; present { + t.Fatalf("important_update should be absent when there is no message: %q", out) + } + }) +} diff --git a/docs/motd.md b/docs/motd.md new file mode 100644 index 00000000..38c2aaf6 --- /dev/null +++ b/docs/motd.md @@ -0,0 +1,84 @@ +# Message of the day (MOTD) + +The message-of-the-day mechanism shows a short, centrally-managed banner +ahead of **every** `pilotctl` command, for one UTC calendar day at a time. +It is used for network-wide notices: maintenance windows, incident updates, +breaking-change heads-ups. + +``` +$ pilotctl info +Message of the day: overlay maintenance 22:00 UTC — expect ~5min blips + + +``` + +When no message is active for the current UTC day, output is unchanged. +Messages are managed centrally by the Pilot Protocol team; there is nothing +to configure on a client to receive them. + +## Design + +Two rules drive the design: + +1. **`pilotctl` must stay fast and never call the network or the daemon just + to render the banner.** +2. **The daemon must not make an on-demand call when a command runs.** + +So the work is split: + +- The **daemon** is the only component that touches the network. A background + loop (`motdPollLoop`) fetches the feed every `--motd-interval` (default + 15m), selects the entry dated for the current UTC day, holds it in memory, + and **mirrors** it to `~/.pilot/motd.json`. +- **`pilotctl`** reads only that local mirror — one file read — and + re-validates the UTC day on read, so a stale mirror (e.g. the daemon was + offline across midnight) never shows yesterday's message. + +``` + central feed ──poll──► pilot-daemon ──mirror──► ~/.pilot/motd.json + (managed by the (only net I/O) (the local variable) + Pilot team) │ read (no net, no IPC) + ▼ + pilotctl ──► prepends banner +``` + +No new binary ships: the poll is a goroutine inside `pilot-daemon`, modelled +on the existing skill-reconciler loop. + +## Output behaviour + +- **Text mode:** the banner is prepended to stdout as + `Message of the day: ` followed by a blank line, then the normal + command output. +- **`--json` mode:** no text is prepended (it would break parsing). Instead + the standard envelope carries a top-level `important_update` field: + + ```json + { "status": "ok", "data": { ... }, "important_update": "overlay maintenance 22:00 UTC" } + ``` + + The same field is added to error envelopes. The daemon also surfaces the + current value as `motd` in `pilotctl info`. + +## Configuration + +`pilot-daemon` flags (also settable via `pilotctl daemon start`): + +| Flag | Default | Meaning | +|------|---------|---------| +| `--motd-feed-url ` | the central feed URL | feed location; **empty disables** polling entirely | +| `--motd-interval ` | `15m` | how often to re-fetch | +| `$PILOT_MOTD_URL` | — | env override for the feed URL | + +The mirror lives next to the daemon identity (normally `~/.pilot/motd.json`), +which is where `pilotctl` looks. + +## Semantics + +- **UTC days.** A message is active only on its UTC calendar day. `pilotctl` + re-checks the day on read, so a message never lingers past its UTC day. +- **Self-clearing.** When the active message is withdrawn, the mirror is + cleared within one poll interval and the banner disappears on its own. +- **Fail-safe.** Non-2xx responses and parse errors are non-fatal: the daemon + keeps its last good mirror and logs at debug level. An unknown + `schema_version` is rejected rather than mis-parsed. diff --git a/internal/motd/motd.go b/internal/motd/motd.go new file mode 100644 index 00000000..5637c66c --- /dev/null +++ b/internal/motd/motd.go @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package motd implements the "message of the day" mechanism: a small +// always-on banner that the daemon polls from a static JSON feed and +// mirrors to a local file, and that pilotctl prepends to every command's +// output. +// +// The split of responsibilities is deliberate: +// +// - The daemon is the ONLY component that touches the network. A +// background loop fetches the feed on an interval, picks the entry +// active for the current UTC day, and writes it to the local mirror. +// - pilotctl only ever reads the mirror — one local file read, no HTTP, +// no IPC — so every command stays fast and works even if the daemon is +// momentarily unreachable. +// +// Clearing is a first-class operation: an empty feed, a feed with no entry +// for today, or a feed the operator emptied on purpose all resolve to a +// cleared mirror, so the banner disappears on its own within one poll +// interval. +package motd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + // DefaultFeedURL is the canonical message-of-the-day source: the raw + // contents of motd.json on the pilot-motd repo's default branch. A + // commit there propagates to every daemon on its next poll (subject to + // GitHub's raw CDN cache, typically a few minutes). + DefaultFeedURL = "https://raw.githubusercontent.com/pilot-protocol/pilot-motd/main/motd.json" + + // DefaultInterval is how often the daemon re-fetches the feed when no + // interval is configured. + DefaultInterval = 15 * time.Minute + + // SchemaVersion is the feed schema this build understands. A feed may + // omit the field (treated as compatible); a feed that declares a + // different non-zero version is rejected. + SchemaVersion = 1 + + // maxFeedBytes caps how much of the feed body we read. The feed is a + // handful of short strings; anything larger is malformed or hostile. + maxFeedBytes = 64 * 1024 +) + +// Message is a single dated message-of-the-day entry. +type Message struct { + Date string `json:"date"` // UTC calendar day, "YYYY-MM-DD" + Text string `json:"text"` + ID string `json:"id,omitempty"` +} + +// Feed is the on-the-wire shape served at the feed URL. +type Feed struct { + SchemaVersion int `json:"schema_version"` + Messages []Message `json:"messages"` +} + +// Mirror is the local materialized "variable" the CLI reads. It holds at +// most the single message active for Date. An empty Text — or a Date that +// no longer matches today — means "no banner". +type Mirror struct { + Date string `json:"date"` + Text string `json:"text"` + UpdatedAt time.Time `json:"updated_at"` +} + +// DayKey returns the UTC calendar-day key ("YYYY-MM-DD") for t. +func DayKey(t time.Time) string { + return t.UTC().Format("2006-01-02") +} + +// Fetch retrieves and parses the feed at url. The caller supplies the +// http.Client so the daemon owns the timeout policy. A non-2xx status or an +// unknown schema version is an error; an empty body is treated as an empty +// feed so operators can clear the board by committing an empty file. +func Fetch(ctx context.Context, client *http.Client, url string) (Feed, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Feed{}, err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return Feed{}, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return Feed{}, fmt.Errorf("motd: HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxFeedBytes)) + if err != nil { + return Feed{}, err + } + return Parse(body) +} + +// Parse decodes feed JSON. Empty or whitespace-only input is treated as an +// empty feed (no messages) rather than an error. +func Parse(body []byte) (Feed, error) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return Feed{SchemaVersion: SchemaVersion}, nil + } + var f Feed + if err := json.Unmarshal(trimmed, &f); err != nil { + return Feed{}, fmt.Errorf("motd: parse feed: %w", err) + } + if f.SchemaVersion != 0 && f.SchemaVersion != SchemaVersion { + return Feed{}, fmt.Errorf("motd: unsupported schema_version %d (want %d)", f.SchemaVersion, SchemaVersion) + } + return f, nil +} + +// SelectForToday returns the message whose Date equals the UTC day of now. +// The second result is false when no message is active (no entry for today, +// or its text is blank). When several entries share today's date the first +// non-blank one wins — operators are expected to keep one per day. +func SelectForToday(f Feed, now time.Time) (Message, bool) { + today := DayKey(now) + for _, m := range f.Messages { + if strings.TrimSpace(m.Date) == today && strings.TrimSpace(m.Text) != "" { + return m, true + } + } + return Message{}, false +} + +// WriteMirror atomically writes the active message to path. Passing a +// message with blank Text writes a cleared mirror (so a reader can tell +// "checked, nothing today" from "never written"). now stamps UpdatedAt. +// The parent directory is created if missing. +func WriteMirror(path string, m Message, now time.Time) error { + mir := Mirror{Date: m.Date, Text: m.Text, UpdatedAt: now.UTC()} + if strings.TrimSpace(m.Text) == "" { + mir.Date = "" + mir.Text = "" + } + data, err := json.MarshalIndent(mir, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if dir := filepath.Dir(path); dir != "" { + _ = os.MkdirAll(dir, 0o700) + } + return atomicWrite(path, data) +} + +// ReadActiveMirror reads the local mirror and returns the banner text active +// for the UTC day of now. It returns ("", false) when the file is absent, +// empty, malformed, blank, or dated for a day other than today — so a stale +// mirror (e.g. the daemon was offline across a UTC midnight) never shows +// yesterday's message. This is the only function pilotctl calls on the hot +// path: a single local file read, no network. +func ReadActiveMirror(path string, now time.Time) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + var mir Mirror + if err := json.Unmarshal(data, &mir); err != nil { + return "", false + } + text := strings.TrimSpace(mir.Text) + if text == "" { + return "", false + } + if strings.TrimSpace(mir.Date) != DayKey(now) { + return "", false + } + return text, true +} + +// atomicWrite writes data to path via a temp file + rename, so the target is +// never observed in a truncated state. Kept inline (rather than importing a +// helper) so this package stays a pure-stdlib leaf importable from any layer. +func atomicWrite(path string, data []byte) error { + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/motd/motd_test.go b/internal/motd/motd_test.go new file mode 100644 index 00000000..8f0d0ec0 --- /dev/null +++ b/internal/motd/motd_test.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package motd + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +func mustTime(t *testing.T, s string) time.Time { + t.Helper() + tm, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("parse time %q: %v", s, err) + } + return tm +} + +func TestDayKeyIsUTC(t *testing.T) { + // 2026-06-15T23:30 in UTC+2 is still 2026-06-15 in UTC, but a naive + // local-time format would roll to the 16th. DayKey must use UTC. + loc := time.FixedZone("UTC+2", 2*3600) + local := time.Date(2026, 6, 15, 23, 30, 0, 0, loc) // 21:30 UTC + if got := DayKey(local); got != "2026-06-15" { + t.Fatalf("DayKey = %q, want 2026-06-15", got) + } + past := time.Date(2026, 6, 16, 1, 0, 0, 0, loc) // 23:00 UTC on the 15th + if got := DayKey(past); got != "2026-06-15" { + t.Fatalf("DayKey = %q, want 2026-06-15 (UTC of 16th 01:00 +2)", got) + } +} + +func TestParse(t *testing.T) { + t.Run("empty body is an empty feed", func(t *testing.T) { + f, err := Parse([]byte(" \n")) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(f.Messages) != 0 { + t.Fatalf("want 0 messages, got %d", len(f.Messages)) + } + }) + t.Run("good feed", func(t *testing.T) { + f, err := Parse([]byte(`{"schema_version":1,"messages":[{"date":"2026-06-15","text":"hi"}]}`)) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(f.Messages) != 1 || f.Messages[0].Text != "hi" { + t.Fatalf("unexpected feed: %+v", f) + } + }) + t.Run("unknown schema version rejected", func(t *testing.T) { + if _, err := Parse([]byte(`{"schema_version":99,"messages":[]}`)); err == nil { + t.Fatal("want error for schema_version 99") + } + }) + t.Run("missing schema version tolerated", func(t *testing.T) { + if _, err := Parse([]byte(`{"messages":[]}`)); err != nil { + t.Fatalf("unexpected err: %v", err) + } + }) + t.Run("malformed json", func(t *testing.T) { + if _, err := Parse([]byte(`{not json`)); err == nil { + t.Fatal("want parse error") + } + }) +} + +func TestSelectForToday(t *testing.T) { + now := mustTime(t, "2026-06-15T12:00:00Z") + feed := Feed{SchemaVersion: 1, Messages: []Message{ + {Date: "2026-06-14", Text: "yesterday"}, + {Date: "2026-06-15", Text: "today wins"}, + {Date: "2026-06-15", Text: "second today, ignored"}, + {Date: "2026-06-16", Text: "tomorrow"}, + }} + m, ok := SelectForToday(feed, now) + if !ok || m.Text != "today wins" { + t.Fatalf("SelectForToday = %q,%v; want today wins,true", m.Text, ok) + } + + t.Run("no entry today", func(t *testing.T) { + _, ok := SelectForToday(Feed{Messages: []Message{{Date: "2026-06-14", Text: "x"}}}, now) + if ok { + t.Fatal("want no active message") + } + }) + t.Run("blank text skipped", func(t *testing.T) { + _, ok := SelectForToday(Feed{Messages: []Message{{Date: "2026-06-15", Text: " "}}}, now) + if ok { + t.Fatal("blank text should not be active") + } + }) + t.Run("empty feed", func(t *testing.T) { + if _, ok := SelectForToday(Feed{}, now); ok { + t.Fatal("empty feed has no active message") + } + }) +} + +func TestMirrorRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "motd.json") + now := mustTime(t, "2026-06-15T08:00:00Z") + + // Write an active message; reading on the same UTC day returns it. + if err := WriteMirror(path, Message{Date: "2026-06-15", Text: "maintenance 22:00 UTC"}, now); err != nil { + t.Fatalf("write: %v", err) + } + got, ok := ReadActiveMirror(path, now) + if !ok || got != "maintenance 22:00 UTC" { + t.Fatalf("read = %q,%v; want the message,true", got, ok) + } + + // The next UTC day must not show yesterday's message even if the mirror + // is untouched (daemon offline across midnight). + nextDay := mustTime(t, "2026-06-16T08:00:00Z") + if _, ok := ReadActiveMirror(path, nextDay); ok { + t.Fatal("stale mirror must not be active on a later UTC day") + } +} + +func TestMirrorCreatesParentDir(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nested", "deeper", "motd.json") + now := mustTime(t, "2026-06-15T08:00:00Z") + if err := WriteMirror(path, Message{Date: "2026-06-15", Text: "x"}, now); err != nil { + t.Fatalf("write into missing dir: %v", err) + } + if _, ok := ReadActiveMirror(path, now); !ok { + t.Fatal("expected active message after writing into nested dir") + } +} + +func TestClearedMirror(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "motd.json") + now := mustTime(t, "2026-06-15T08:00:00Z") + + // First post a message, then clear it (blank text) — the banner must + // disappear. This is the "committing an empty motd updates the value" + // path: SelectForToday returns a zero Message, which WriteMirror stores + // as a cleared mirror. + if err := WriteMirror(path, Message{Date: "2026-06-15", Text: "hello"}, now); err != nil { + t.Fatalf("write: %v", err) + } + if _, ok := ReadActiveMirror(path, now); !ok { + t.Fatal("precondition: message should be active") + } + if err := WriteMirror(path, Message{}, now); err != nil { + t.Fatalf("clear: %v", err) + } + if _, ok := ReadActiveMirror(path, now); ok { + t.Fatal("cleared mirror must not be active") + } +} + +func TestReadActiveMirrorMissingFile(t *testing.T) { + if _, ok := ReadActiveMirror(filepath.Join(t.TempDir(), "nope.json"), time.Now()); ok { + t.Fatal("missing file must yield no banner") + } +} + +func TestFetch(t *testing.T) { + now := mustTime(t, "2026-06-15T12:00:00Z") + + t.Run("serves and selects today", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"schema_version":1,"messages":[{"date":"2026-06-15","text":"served"}]}`)) + })) + defer srv.Close() + feed, err := Fetch(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("fetch: %v", err) + } + m, ok := SelectForToday(feed, now) + if !ok || m.Text != "served" { + t.Fatalf("select = %q,%v", m.Text, ok) + } + }) + + t.Run("non-2xx is an error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + if _, err := Fetch(context.Background(), srv.Client(), srv.URL); err == nil { + t.Fatal("want error on HTTP 500") + } + }) + + t.Run("empty body clears", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + feed, err := Fetch(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if _, ok := SelectForToday(feed, now); ok { + t.Fatal("empty feed must have no active message") + } + }) +} diff --git a/layers.yaml b/layers.yaml index 297fcae8..cfb654e2 100644 --- a/layers.yaml +++ b/layers.yaml @@ -110,6 +110,7 @@ utilities: - github.com/TeoSlayer/pilotprotocol/internal/account - github.com/TeoSlayer/pilotprotocol/internal/validate - github.com/TeoSlayer/pilotprotocol/internal/nodesapi + - github.com/TeoSlayer/pilotprotocol/internal/motd - github.com/TeoSlayer/pilotprotocol/pkg/secure - github.com/TeoSlayer/pilotprotocol/pkg/config - github.com/TeoSlayer/pilotprotocol/pkg/logging diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 09c6df8e..1e71c85c 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -15,6 +15,7 @@ import ( "log/slog" "math/rand" "net" + "net/http" "os" "path/filepath" "strings" @@ -23,6 +24,7 @@ import ( "time" "github.com/TeoSlayer/pilotprotocol/internal/account" + "github.com/TeoSlayer/pilotprotocol/internal/motd" "github.com/TeoSlayer/pilotprotocol/internal/transport/compat" "github.com/TeoSlayer/pilotprotocol/internal/validate" "github.com/pilot-protocol/common/crypto" @@ -121,6 +123,13 @@ type Config struct { // Version Version string // binary version string (injected via LDFLAGS at build time) + // Message of the day. The daemon polls MOTDFeedURL every MOTDInterval, + // selects the entry dated for the current UTC day, mirrors it to + // ~/.pilot/motd.json, and pilotctl renders it as a banner. An empty + // MOTDFeedURL disables polling entirely (no goroutine, no network). + MOTDFeedURL string + MOTDInterval time.Duration // zero = motd.DefaultInterval + // Feature flags — ablation testing. All default false (current behavior). BeaconRTTProbe bool // probe beacon RTT; override hash pick when >2× slower than best @@ -359,6 +368,13 @@ type Daemon struct { nodeNetworksCache []uint16 nodeNetworksCacheAt time.Time + // Message of the day: the text active for the current UTC day, set by + // motdPollLoop from the remote feed and mirrored to ~/.pilot/motd.json + // for the CLI banner. Empty means no banner. The daemon is the only + // component that fetches the feed; pilotctl reads the mirror. + motdMu sync.RWMutex + motd string + // SYN rate limiter (token bucket) synMu sync.Mutex synTokens int @@ -1156,6 +1172,14 @@ func (d *Daemon) Start() error { d.bgWG.Add(1) go func() { defer d.bgWG.Done(); d.hostnameReannounceLoop() }() + // 14. Start message-of-the-day poll loop. Fetches the MOTD feed on an + // interval and mirrors today's (UTC) entry to ~/.pilot/motd.json so + // pilotctl can render the banner without any network or IPC call. The + // loop returns immediately (cheap no-op goroutine) when no feed URL is + // configured. This is the ONLY component that fetches the MOTD feed. + d.bgWG.Add(1) + go func() { defer d.bgWG.Done(); d.motdPollLoop() }() + // 12b. Earlier rc2-dev iterations pre-warmed trusted peers at // startup. Two variants were tried and both made things worse: // @@ -2540,6 +2564,8 @@ type DaemonInfo struct { RelayPeerCount int // peers currently on relay path (symmetric NAT) BeaconAddr string // active beacon address + + MOTD string // message-of-the-day active for the current UTC day ("" = none) } // Info returns current daemon status. @@ -2631,9 +2657,18 @@ func (d *Daemon) Info() *DaemonInfo { WebhookCircuitSkips: d.webhookStats().CircuitSkips, RelayPeerCount: len(d.tunnels.RelayPeerIDs()), BeaconAddr: d.config.BeaconAddr, + MOTD: d.currentMOTD(), } } +// currentMOTD returns the message-of-the-day text active for the current +// UTC day, or "" if none. Safe for concurrent callers. +func (d *Daemon) currentMOTD() string { + d.motdMu.RLock() + defer d.motdMu.RUnlock() + return d.motd +} + func (d *Daemon) routeLoop() { for { select { @@ -4407,6 +4442,87 @@ func (d *Daemon) prewarmTrustedResolves() { slog.Info("prewarm complete", "peers_warmed", warmed) } +// motdMirrorPath returns the on-disk location of the message-of-the-day +// mirror that pilotctl reads. It sits next to the persisted identity +// (normally ~/.pilot/), falling back to $HOME/.pilot when the daemon runs +// without identity persistence so the CLI — which always looks in +// ~/.pilot — still finds it. Empty only if the home directory can't be +// determined. +func (d *Daemon) motdMirrorPath() string { + if d.config.IdentityPath != "" { + return filepath.Join(filepath.Dir(d.config.IdentityPath), "motd.json") + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".pilot", "motd.json") +} + +// motdPollLoop periodically fetches the MOTD feed, selects the entry active +// for the current UTC day, stores it in d.motd, and mirrors it to disk for +// the CLI. A cleared/empty feed (or a removed entry) propagates within one +// interval as a cleared mirror, so the banner disappears on its own. The +// loop is a cheap no-op when no feed URL is configured. +func (d *Daemon) motdPollLoop() { + url := d.config.MOTDFeedURL + if url == "" { + return + } + interval := d.config.MOTDInterval + if interval <= 0 { + interval = motd.DefaultInterval + } + client := &http.Client{Timeout: 10 * time.Second} + + // Fire once on startup so the banner is warm shortly after boot, + // then settle into the interval. + d.refreshMOTD(client, url) + + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-d.stopCh: + return + case <-t.C: + d.refreshMOTD(client, url) + } + } +} + +// refreshMOTD performs one fetch+select+mirror cycle. On a fetch error it +// keeps the last good mirror in place (offline tolerance) — a stale mirror +// can't show an out-of-date message because the CLI re-validates the UTC +// day on read. +func (d *Daemon) refreshMOTD(client *http.Client, url string) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + feed, err := motd.Fetch(ctx, client, url) + if err != nil { + slog.Debug("motd fetch failed; keeping last mirror", "err", err) + return + } + + now := time.Now() + msg, active := motd.SelectForToday(feed, now) + + d.motdMu.Lock() + if active { + d.motd = msg.Text + } else { + d.motd = "" + } + d.motdMu.Unlock() + + if path := d.motdMirrorPath(); path != "" { + if err := motd.WriteMirror(path, msg, now); err != nil { + slog.Debug("motd mirror write failed", "err", err, "path", path) + } + } +} + // hostnameCachePath returns the on-disk location of the persisted // hostname cache, derived from the identity path. Empty if the daemon // is in-memory only (no identity persistence configured). diff --git a/pkg/daemon/ipc.go b/pkg/daemon/ipc.go index 51f3bbc5..0b4fb1b7 100644 --- a/pkg/daemon/ipc.go +++ b/pkg/daemon/ipc.go @@ -1039,6 +1039,7 @@ func (s *IPCServer) handleInfo(conn *ipcConn, reqID uint64) { "webhook_circuit_skips": info.WebhookCircuitSkips, "relay_peer_count": info.RelayPeerCount, "beacon_addr": info.BeaconAddr, + "motd": info.MOTD, }) if err != nil { s.sendError(conn, reqID, fmt.Sprintf("info marshal: %v", err))