From cbdd8a587105a612cc54eafd60103847b71305b1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 09:02:53 +0200 Subject: [PATCH 1/2] feat(cloud): retire the Frankfurt relay from Core Signed-off-by: Fredrik Ahlgren --- .changeset/retire-frankfurt.md | 5 + config.example.yaml | 2 +- docs/protocol-cloud.md | 23 ++++ go/cmd/ftw/app_link.go | 35 +----- go/cmd/ftw/cloud_link.go | 6 +- go/cmd/ftw/main.go | 12 +- go/cmd/ftw/webpush.go | 48 ++++--- go/cmd/ftw/webpush_test.go | 66 ++++++++++ go/internal/appuplink/client.go | 29 +---- go/internal/appuplink/cloudtunnel_test.go | 10 +- go/internal/appuplink/deadman_test.go | 2 +- go/internal/appuplink/exchange_test.go | 2 +- go/internal/appuplink/legacy_test.go | 20 +++ go/internal/appuplink/realrelay_test.go | 2 +- go/internal/appuplink/rotation_test.go | 2 +- go/internal/appuplink/uplink_test.go | 2 +- go/internal/cloudlink/link.go | 6 + go/internal/cloudlink/sender.go | 10 +- go/internal/cloudlink/webpush_test.go | 23 ++++ go/internal/config/config.go | 6 +- go/internal/config/fleet_ping_test.go | 11 ++ go/internal/fleetping/fleetping.go | 13 +- go/protocol/cloudwire/allowlist.go | 13 +- go/protocol/cloudwire/messages.go | 14 +++ .../testdata/cloud-quic-vectors.json | 5 + .../cloudwire/testdata/cloud-vectors.json | 10 ++ go/protocol/cloudwire/vectors_test.go | 1 + go/protocol/cloudwire/webpush.go | 27 ++++ go/protocol/fleetwire/fleet.go | 119 ++++++++++++++++++ go/protocol/fleetwire/fleet_test.go | 47 +++++++ 30 files changed, 465 insertions(+), 106 deletions(-) create mode 100644 .changeset/retire-frankfurt.md create mode 100644 go/cmd/ftw/webpush_test.go create mode 100644 go/internal/appuplink/legacy_test.go create mode 100644 go/internal/cloudlink/webpush_test.go create mode 100644 go/protocol/cloudwire/webpush.go create mode 100644 go/protocol/fleetwire/fleet.go create mode 100644 go/protocol/fleetwire/fleet_test.go diff --git a/.changeset/retire-frankfurt.md b/.changeset/retire-frankfurt.md new file mode 100644 index 000000000..7f109f969 --- /dev/null +++ b/.changeset/retire-frankfurt.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Use FTW Cloud for remote app sessions, anonymous daily totals and encrypted browser offline notices. The box no longer connects to the Frankfurt relay. Older web and phone pairings may need a new QR code. diff --git a/config.example.yaml b/config.example.yaml index a2d2b6437..c2ea888bb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -232,7 +232,7 @@ app_link: # default; `enabled: false` turns it off without a restart. fleet_ping: enabled: true - # endpoint: https://relay.ftw.energy/fleet + # endpoint: https://cloud.ftw.energy/v1/fleet # Independently distributed Lua drivers. The signed stable catalog is refreshed # by default, but refresh never downloads, activates, or restarts a driver. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 22f9dd562..bdf7f7519 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -652,6 +652,29 @@ The service never forwards these to a phone. | `pending` | [Pending] | The pairing codes the box shows now, at most one | | `noise` | bstr, 32 B | The box's X25519 Noise static key, the one in its pairing QR code. FTW Cloud's `keys` part opens an agent's session to it, and trusts it only as signed here | +| `webpush` | [WebPushRow], optional | Box-encrypted browser offline notices. Service only; no browser key leaves the box | + +**WebPushRow** + +| Field | Type | Meaning | +|---|---|---| +| `id` | text | Subscription id, scoped to this home | +| `dev` | text | Current roster device id | +| `epoch` | uint | Its current grant epoch | +| `endpoint` | text | HTTPS address at a supported browser push vendor | +| `ct` | bstr | RFC 8291 encrypted box.unreachable payload | +| `auth` | text | Box-signed VAPID Authorization, renewed within six hours | + +At most 16 rows and 32 KiB of encoded rows fit in one roster. Each ciphertext +has at most 1,024 bytes, endpoint 2,048 bytes and Authorization 512 bytes. + +The service sends these only after the box has been away ten minutes, using +the same timer and update grace as APNs. It checks the current device, epoch, +events scope and suspension before each send. It needs no readable-copy grant. +The rows never reach another phone. It stores only a sent marker per outage +and subscription; purge removes it. Removed subscriptions leave the next roster. +A deploy drains and starts no offline alert. + **Device** | Field | Type | Meaning | diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 314fb398e..a3df6a366 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -23,7 +23,6 @@ import ( "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/mpc" - "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" @@ -672,7 +671,6 @@ func startAppLink( revision *control.Revision, siteMeterStale time.Duration, gateway *lateAPI, - webPush *notifications.WebPush, cloud *cloudSession, agents *agentPorts, ) (*appenroll.Identity, *appuplink.Uplink, bool, error) { @@ -759,23 +757,10 @@ func startAppLink( // starting, which is true. caps = append(caps, appproto.CapApiPassthrough) - // The dead man's switch rides on the uplink exactly when there is - // state to hold subscriptions in and a VAPID key to sign for them — - // a row the push service would refuse is not a farewell. With neither - // it produces no rows and the uplink says no extra word. - var deadman appuplink.DeadmanSource - if st != nil && webPush != nil { - deadman = deadmanFromState{st: st, wp: webPush} - } - // One command log for the box. Sessions come and go with their sockets, // and a retry in the next session must meet the id the last one took. cmds := appproto.NewCmdLog() - // The box takes a watch through the relay's exchange (ADR 0009), and - // says so, so a phone offers "Add watch". - caps = append(caps, appproto.CapCmdExchange) - // Agents (docs/agents.md): an owner's grants to an assistant, Sourceful // support or the release check. They reach the box through FTW Cloud's // tunnel only, and every call meets its grant here. @@ -794,7 +779,7 @@ func startAppLink( // watch's exchange. They share the ports above, which are read-only // apart from the mode and the command log, and the mode goes through // control's own validation. - handlerFor := func(exchange bool) appuplink.HandlerBuilder { + handlerFor := func() appuplink.HandlerBuilder { return func( sender appproto.Sender, caller apiauth.Caller, @@ -817,7 +802,6 @@ func startAppLink( Caller: caller, Grants: grants, Commands: cmds, - Exchange: exchange, Access: agents.access, Agents: agents.state, Audit: agents.audit, @@ -843,18 +827,16 @@ func startAppLink( } uplink, err := appuplink.New(appuplink.Options{ - Enroll: enroll, - Deadman: deadman, - Handler: handlerFor(false), - ExchangeHandler: handlerFor(true), - Arrivals: arrivalsFor(ctx, tel), + Enroll: enroll, + Handler: handlerFor(), + Arrivals: arrivalsFor(ctx, tel), }) if err != nil { return enroll, nil, enabled, err } agents.access.Drop = func(key []byte) { uplink.DropSessionsByAppKey(key) } agents.boxPage = func(by string) (*appproto.Handler, error) { - return handlerFor(false)(discardSender{}, boxPageCaller(by), boxPageGrant{}) + return handlerFor()(discardSender{}, boxPageCaller(by), boxPageGrant{}) } go sweepAgents(ctx, agents) @@ -1053,7 +1035,6 @@ func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error { a.agents.agentGone(gone.ID, "app-link") } } - var swept int64 for _, gone := range removed { // Forgetting the key locks the next handshake out; dropping the // live sessions locks out the one running now. Both, or "remove" @@ -1063,7 +1044,7 @@ func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error { } // A device that may no longer read the house may no longer be told // about it: its push subscriptions go with the grant, and the - // relay's pre-encrypted farewell rows for them go on the resync. + // Cloud roster's encrypted offline notices for them go on the resync. if a.st != nil { n, err := a.st.DeletePushSubscriptionsByDevice(gone.ID) switch { @@ -1071,13 +1052,9 @@ func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error { slog.Warn("could not sweep a revoked device's push subscriptions", "device", gone.ID, "err", err) case n > 0: slog.Info("swept a revoked device's push subscriptions", "device", gone.ID, "rows", n) - swept += n } } } - if swept > 0 && a.uplink != nil { - go a.uplink.ResyncDeadman() - } return nil } diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 146826162..948c2648f 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -17,6 +17,7 @@ import ( "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/appuplink" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/cloudlink" "github.com/srcfl/ftw/go/internal/control" @@ -381,7 +382,7 @@ func startCloudLink( ctx context.Context, statePath, build string, st *state.Store, enroll *appenroll.Identity, tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, revision *control.Revision, siteMeterStale time.Duration, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices, - tunnel *appuplink.Tunnel, carsRT *cars.Runtime, + tunnel *appuplink.Tunnel, carsRT *cars.Runtime, webPush *notifications.WebPush, ) *cloudlink.Link { if st == nil || enroll == nil { return nil @@ -404,6 +405,9 @@ func startCloudLink( Notify: notices.notify, Arrivals: arrivalsFor(ctx, tel), } + if webPush != nil { + opts.WebPush = webPushFromState{st: st, wp: webPush, enroll: enroll} + } // Set only with a table: a typed nil would read as a tunnel. if tunnel != nil { opts.Tunnel = tunnel diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index df204de03..d971f274e 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2404,7 +2404,7 @@ func main() { appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, st, tel, mpcSvc, lpMgr, lpController, carsRT, priceSvc, ctrl, ctrlMu, plannerPrefs, - controlRev, appLinkWatchdog, appAPI, webPush, appCloud, agents, + controlRev, appLinkWatchdog, appAPI, appCloud, agents, ) switch { case appLinkErr != nil: @@ -2419,13 +2419,13 @@ func main() { // FTW Cloud: the phones' own sessions with the box, carried as // ciphertext the service cannot read, from the first pairing on; and a // signed, readable copy of the house, off until an owner turns it on. - // The relay keeps running beside it. + // Both use the one FTW Cloud connection. var appTunnel *appuplink.Tunnel if appUplink != nil { appTunnel = appUplink.Tunnel() } cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll, - tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel, carsRT) + tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel, carsRT, webPush) appCloud.bind(cloudLink) if cloudLink != nil { agents.home = cloudLink.Home @@ -2433,7 +2433,7 @@ func main() { } // The anonymous fleet ping: how many boxes, which versions, which drivers. - // Its separate relay HTTP door keeps daily totals; the encrypted app path + // Its separate Cloud HTTP route keeps daily totals; the encrypted app path // stays opaque and stores no routed traffic. // The bundled drivers dir is deliberately not passed: which drivers this // build ships with is compiled in, not discovered on disk. See @@ -2661,8 +2661,8 @@ func main() { Plugged: lp.PluggedIn, LevelKnown: lp.PluggedIn && lp.SoCSource != "assumed", HasGoal: lp.TargetSoC > 0, }, true }) - if appUplink != nil { - deps.PushResync = appUplink.ResyncDeadman + if cloudLink != nil { + deps.PushResync = cloudLink.RosterChanged } // The loadpoint manager publishes its session latches (completion, // interruption) on the same bus the engine listens to. diff --git a/go/cmd/ftw/webpush.go b/go/cmd/ftw/webpush.go index 48fa000a2..20f3e0527 100644 --- a/go/cmd/ftw/webpush.go +++ b/go/cmd/ftw/webpush.go @@ -5,9 +5,10 @@ import ( "fmt" "log/slog" - "github.com/srcfl/ftw/go/internal/appuplink" + "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // The web-push glue: state.Store adapted onto the narrow interfaces the @@ -37,20 +38,21 @@ func (p pushSubscriptionSource) DeletePushSubscription(id string) error { return p.st.DeletePushSubscription(id) } -// deadmanFromState renders and encrypts the box.unreachable farewell, one +// webPushFromState renders and encrypts the box.unreachable farewell, one // row per stored subscription. The sentence comes from the push catalogue // and the encryption is the same RFC 8291 construction every live push -// uses — the relay stores it and can read none of it. Each row also carries +// uses — FTW Cloud stores it and can read none of it. Each row also carries // the VAPID Authorization header the push service will demand, signed here -// by the provider's own key because the relay must never hold that key; the -// uplink calls back every six hours, so the signature is re-made long +// by the provider's own key because the service must never hold that key; the +// roster calls back every six hours, so the signature is re-made long // before its 24 hours run out. -type deadmanFromState struct { - st *state.Store - wp *notifications.WebPush +type webPushFromState struct { + st *state.Store + wp *notifications.WebPush + enroll *appenroll.Identity } -func (d deadmanFromState) DeadmanRows() ([]appuplink.DeadmanRow, error) { +func (d webPushFromState) WebPushRows() ([]cloudwire.WebPushRow, error) { subs, err := d.st.PushSubscriptions() if err != nil { return nil, err @@ -66,25 +68,35 @@ func (d deadmanFromState) DeadmanRows() ([]appuplink.DeadmanRow, error) { if err != nil { return nil, err } - rows := make([]appuplink.DeadmanRow, 0, len(subs)) + rows := make([]cloudwire.WebPushRow, 0, len(subs)) for _, sub := range subs { + device, ok := d.enroll.GrantFor(sub.DeviceID) + if !ok { + continue + } ct, err := notifications.EncryptForSubscription(sub.P256dh, sub.Auth, payload) if err != nil { // One unencryptable subscription must not disarm the rest. - slog.Warn("dead man's switch: skipping a subscription", "id", sub.ID, "err", err) + slog.Warn("dead man's switch: skipping a subscription", "err", err) continue } auth, err := d.wp.DeadmanAuthorization(sub.Endpoint) if err != nil { - slog.Warn("dead man's switch: skipping a subscription", "id", sub.ID, "err", err) + slog.Warn("dead man's switch: skipping a subscription", "err", err) continue } - rows = append(rows, appuplink.DeadmanRow{ - SubscriptionID: sub.ID, - Endpoint: sub.Endpoint, - CT: ct, - Auth: auth, - }) + row := cloudwire.WebPushRow{ + ID: sub.ID, Device: sub.DeviceID, Epoch: device.Epoch, + Endpoint: sub.Endpoint, + CT: ct, + Auth: auth, + } + proposed := append(rows, row) + if cloudwire.ValidateWebPush(proposed) != nil { + slog.Warn("browser offline notice limit reached") + break + } + rows = proposed } return rows, nil } diff --git a/go/cmd/ftw/webpush_test.go b/go/cmd/ftw/webpush_test.go new file mode 100644 index 000000000..df842aa3d --- /dev/null +++ b/go/cmd/ftw/webpush_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/notifications" + "github.com/srcfl/ftw/go/internal/nova" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/protocol/appwire" + "path/filepath" + "testing" +) + +func TestBrowserFarewellsStayEncryptedAndFollowTheCurrentDeviceEpoch(t *testing.T) { + dir := t.TempDir() + st, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + enroll, err := appenroll.LoadOrCreate(filepath.Join(dir, "app.key")) + if err != nil { + t.Fatal(err) + } + app, _ := appwire.GenerateKeyPair() + code, _, _ := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + grant, err := enroll.Authorise(app.Public(), code) + if err != nil { + t.Fatal(err) + } + key, err := nova.LoadOrCreateIdentity(filepath.Join(dir, "webpush.key")) + if err != nil { + t.Fatal(err) + } + wp, err := notifications.NewWebPush(key, pushSubscriptionSource{st: st}) + if err != nil { + t.Fatal(err) + } + browser, _ := ecdh.P256().GenerateKey(rand.Reader) + auth := make([]byte, 16) + rand.Read(auth) + _, err = st.SavePushSubscription("https://push.example/opaque", base64.RawURLEncoding.EncodeToString(browser.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth), grant.DeviceID) + if err != nil { + t.Fatal(err) + } + source := webPushFromState{st: st, wp: wp, enroll: enroll} + rows, err := source.WebPushRows() + if err != nil || len(rows) != 1 { + t.Fatal(rows, err) + } + if rows[0].Device != grant.DeviceID || rows[0].Epoch != grant.Epoch || bytes.Contains(rows[0].CT, []byte("title")) { + t.Fatal("grant or encryption") + } + // A revoke makes the row disappear even before the subscription sweep. + if _, err = enroll.Revoke(grant.DeviceID, appenroll.Presence(true)); err != nil { + t.Fatal(err) + } + rows, err = source.WebPushRows() + if err != nil || len(rows) != 0 { + t.Fatal(rows, err) + } +} diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index 225dca02d..a48216432 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -321,32 +321,11 @@ func productionDialer() *websocket.Dialer { } } -// Run keeps the uplink up until the context ends. The tunnel's table runs -// beside it, so a phone reaches the box through FTW Cloud while the relay -// cannot be reached. +// Run serves phone sessions carried by FTW Cloud until the context ends. +// It opens no connection to the retired relay. func (u *Uplink) Run(ctx context.Context) error { - go u.tunnel.Run(ctx) - for { - if ctx.Err() != nil { - return ctx.Err() - } - - delay, err := u.once(ctx) - if ctx.Err() != nil { - return ctx.Err() - } - if err != nil { - u.log.Warn("app uplink dropped; retrying", "retry_in", delay, "err", err) - } - - timer := time.NewTimer(delay) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } + u.tunnel.Run(ctx) + return ctx.Err() } // once dials, serves until the connection ends, and says how long to wait. diff --git a/go/internal/appuplink/cloudtunnel_test.go b/go/internal/appuplink/cloudtunnel_test.go index 2b72b3fba..3f9cb4f32 100644 --- a/go/internal/appuplink/cloudtunnel_test.go +++ b/go/internal/appuplink/cloudtunnel_test.go @@ -254,7 +254,15 @@ func TestACommandThroughFTWCloudWithTheRelayGone(t *testing.T) { SrcGrid: "meter", SrcPV: "meter", SrcBattery: "meter", Logger: slog.New(slog.DiscardHandler), }) } - relay := unreachable(t) + var relayDials atomic.Int64 + trap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { relayDials.Add(1); w.WriteHeader(503) })) + t.Cleanup(trap.Close) + relay := "ws" + trap.URL[4:] + t.Cleanup(func() { + if relayDials.Load() != 0 { + t.Errorf("retired relay dialled %d times", relayDials.Load()) + } + }) uplink, err := New(Options{Endpoint: relay, Enroll: enroll, Handler: build, Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatal(err) diff --git a/go/internal/appuplink/deadman_test.go b/go/internal/appuplink/deadman_test.go index 425d10f77..86bfbcd48 100644 --- a/go/internal/appuplink/deadman_test.go +++ b/go/internal/appuplink/deadman_test.go @@ -143,7 +143,7 @@ func newDeadmanRig(t *testing.T, epoch int64, source DeadmanSource, now func() t ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) done := make(chan error, 1) - go func() { done <- uplink.Run(ctx) }() + go func() { done <- uplink.runRelay(ctx) }() return &rig{relay: relay, enroll: enroll, uplink: uplink, cancel: cancel, done: done} } diff --git a/go/internal/appuplink/exchange_test.go b/go/internal/appuplink/exchange_test.go index 6fe9770d3..d33917d41 100644 --- a/go/internal/appuplink/exchange_test.go +++ b/go/internal/appuplink/exchange_test.go @@ -687,7 +687,7 @@ func newExchangeRig(t *testing.T, charger *fakeCharger) *rig { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) done := make(chan error, 1) - go func() { done <- uplink.Run(ctx) }() + go func() { done <- uplink.runRelay(ctx) }() return &rig{relay: relay, enroll: enroll, uplink: uplink, cancel: cancel, done: done} } diff --git a/go/internal/appuplink/legacy_test.go b/go/internal/appuplink/legacy_test.go new file mode 100644 index 000000000..bbd20be88 --- /dev/null +++ b/go/internal/appuplink/legacy_test.go @@ -0,0 +1,20 @@ +package appuplink + +import ( + "context" + "time" +) + +// runRelay is kept only for the old protocol's regression fixtures. +func (u *Uplink) runRelay(ctx context.Context) error { + for ctx.Err() == nil { + delay, _ := u.once(ctx) + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + case <-timer.C: + } + } + return ctx.Err() +} diff --git a/go/internal/appuplink/realrelay_test.go b/go/internal/appuplink/realrelay_test.go index e781d02dd..080373f74 100644 --- a/go/internal/appuplink/realrelay_test.go +++ b/go/internal/appuplink/realrelay_test.go @@ -57,7 +57,7 @@ func TestExchangeThroughARealRelay(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go func() { _ = uplink.Run(ctx) }() + go func() { _ = uplink.runRelay(ctx) }() code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) if err != nil { diff --git a/go/internal/appuplink/rotation_test.go b/go/internal/appuplink/rotation_test.go index 68eed1239..8e655fce7 100644 --- a/go/internal/appuplink/rotation_test.go +++ b/go/internal/appuplink/rotation_test.go @@ -289,7 +289,7 @@ func newRotationRig(t *testing.T, epoch int64) *rotationRig { ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) - go func() { done <- u.Run(ctx) }() + go func() { done <- u.runRelay(ctx) }() t.Cleanup(func() { cancel() <-done diff --git a/go/internal/appuplink/uplink_test.go b/go/internal/appuplink/uplink_test.go index cbc726ae0..ba6f1ad79 100644 --- a/go/internal/appuplink/uplink_test.go +++ b/go/internal/appuplink/uplink_test.go @@ -152,7 +152,7 @@ func newRigOpts(t *testing.T, epoch int64, set func(*Options)) *rig { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) done := make(chan error, 1) - go func() { done <- uplink.Run(ctx) }() + go func() { done <- uplink.runRelay(ctx) }() return &rig{relay: relay, enroll: enroll, uplink: uplink, cancel: cancel, done: done} } diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 077896a35..4e52372ba 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -187,9 +187,15 @@ type Tunnel interface { Drop() } +// WebPushSource renders and encrypts the notices the service may send when the box is away. +type WebPushSource interface { + WebPushRows() ([]cloudwire.WebPushRow, error) +} + // Options wires a link. Site, Roster, KV and KeyPath are required. type Options struct { Site Site + WebPush WebPushSource Roster Roster KV KV KeyPath string diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 54b905f77..50e94efbb 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -701,6 +701,14 @@ func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { } sort.Slice(r.Devices, func(a, b int) bool { return r.Devices[a].ID < r.Devices[b].ID }) r.Noise = l.opts.Roster.Noise() + if l.opts.WebPush != nil { + var err error + r.WebPush, err = l.opts.WebPush.WebPushRows() + if err != nil { + l.log.Warn("could not prepare browser offline notices") + } + sort.Slice(r.WebPush, func(a, b int) bool { return r.WebPush[a].ID < r.WebPush[b].ID }) + } if code, expires, ok := l.opts.Roster.Pending(); ok && snap.home != "" { if left := time.Until(expires); left > 0 { r.Pending = append(r.Pending, cloudwire.Pending{ @@ -715,7 +723,7 @@ func (l *Link) rosterNumber(snap grantSnapshot) (uint64, cloudwire.Roster) { for _, p := range r.Pending { pending = append(pending, p.Hash) } - sum := digest([]any{r.Devices, r.Grant, pending, r.Noise}) + sum := digest([]any{r.Devices, r.Grant, pending, r.Noise, r.WebPush}) l.mu.Lock() defer l.mu.Unlock() diff --git a/go/internal/cloudlink/webpush_test.go b/go/internal/cloudlink/webpush_test.go new file mode 100644 index 000000000..5382ec7de --- /dev/null +++ b/go/internal/cloudlink/webpush_test.go @@ -0,0 +1,23 @@ +package cloudlink + +import ( + "crypto/ecdsa" + "github.com/srcfl/ftw/go/protocol/cloudwire" + "slices" + "testing" +) + +type browserRows []cloudwire.WebPushRow + +func (r browserRows) WebPushRows() ([]cloudwire.WebPushRow, error) { return r, nil } + +func TestBrowserNoticesUseTheSignedRosterWhileTheReadableCopyIsOff(t *testing.T) { + svc := newFakeService(t) + rows := browserRows{{ID: "sub", Device: "phone", Epoch: 1, Endpoint: "https://push.example/x", CT: []byte("ciphertext"), Auth: "box signature"}} + h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { o.WebPush = rows }) + h.setRoster(func(r *fakeRosterState) { r.tun = true }) + r := h.nextRoster(t) + if len(r.Grant.ID) != 0 || len(r.WebPush) != 1 || !slices.Equal(r.WebPush[0].CT, rows[0].CT) { + t.Fatal("notice missing or copy grant added") + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index bfdf98bc8..f468adbf8 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -247,10 +247,10 @@ type FleetPing struct { Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty"` } -// DefaultFleetPingEndpoint is the fleet-report door on FTW's relay. The relay +// DefaultFleetPingEndpoint is the anonymous fleet-report route on FTW Cloud. It // adds the report to daily totals and discards the request; a failed ping is // forgotten, never retried. -const DefaultFleetPingEndpoint = "https://relay.ftw.energy/fleet" +const DefaultFleetPingEndpoint = "https://cloud.ftw.energy/v1/fleet" // On reports whether the ping is switched on, and is the only place that // answers it. @@ -274,7 +274,7 @@ func (f *FleetPing) On() bool { // this address after FTW moved the collector, and the aggregate would // quietly lose every box that had ever saved its settings. func (f *FleetPing) Resolved() string { - if f == nil || f.Endpoint == "" { + if f == nil || f.Endpoint == "" || strings.TrimSpace(f.Endpoint) == "https://relay.ftw.energy/fleet/report" { return DefaultFleetPingEndpoint } return f.Endpoint diff --git a/go/internal/config/fleet_ping_test.go b/go/internal/config/fleet_ping_test.go index 7f5be8530..0a209e160 100644 --- a/go/internal/config/fleet_ping_test.go +++ b/go/internal/config/fleet_ping_test.go @@ -129,3 +129,14 @@ func TestFleetPingSwitchNeedsNoRestartButTheEndpointDoes(t *testing.T) { t.Errorf("moving the endpoint gave %v, want one fleet_ping.endpoint reason", reasons) } } + +func TestAnExplicitRetiredFleetCollectorResolvesToCloud(t *testing.T) { + f := &FleetPing{Enabled: true, Endpoint: "https://relay.ftw.energy/fleet/report"} + if f.Resolved() != DefaultFleetPingEndpoint { + t.Fatal(f.Resolved()) + } + f.Endpoint = "https://my-collector.example/report" + if f.Resolved() != f.Endpoint { + t.Fatal("changed a private collector") + } +} diff --git a/go/internal/fleetping/fleetping.go b/go/internal/fleetping/fleetping.go index 33e4afa2c..812349f48 100644 --- a/go/internal/fleetping/fleetping.go +++ b/go/internal/fleetping/fleetping.go @@ -53,11 +53,12 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/prices" + "github.com/srcfl/ftw/go/protocol/fleetwire" ) // Schema names the payload shape so the endpoint can read old pings after the // fields change. It is the one constant string in the message. -const Schema = "ftw.fleet/1" +const Schema = fleetwire.Schema const ( // period is the nominal gap between pings. @@ -128,15 +129,7 @@ type Facts struct { // Payload is the whole ping. Every field is a bucket or a label shared with // thousands of other boxes; there is deliberately no field a household could // be looked up by, and no field that says which ping came first. -type Payload struct { - Schema string `json:"schema"` - Version string `json:"ftw_version"` - Channel string `json:"channel"` - Drivers []string `json:"drivers"` - Battery string `json:"battery_kwh"` - Zone string `json:"price_zone"` - Age string `json:"install_age"` -} +type Payload = fleetwire.Report // Build coarsens one box's facts into the message that may leave it. // diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 50a985ffe..6808b38b2 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -41,15 +41,16 @@ var Fields = map[string][]string{ MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp", "cars"}, - MsgRoster: {"rosterSeq", "ttlS", "devices", "grant", "pending", "noise"}, + MsgRoster: {"rosterSeq", "ttlS", "devices", "grant", "pending", "noise", "webpush"}, "Device": {"id", "key", "role", "scopes", "epoch", "tun", "agent"}, // An agent row's grant (docs/agents.md). The client's name and the // ticket came from FTW Cloud's own consent page and support system. - "Agent": {"scopes", "ops", "caps", "expiresAtMs", "principal"}, - "Caps": {"holdS", "maxW", "perMin"}, - "Principal": {"client", "clientId", "ticket"}, - "Grant": {"id", "scopes", "purposes", "controller"}, - "Pending": {"hash", "ttlS"}, + "Agent": {"scopes", "ops", "caps", "expiresAtMs", "principal"}, + "Caps": {"holdS", "maxW", "perMin"}, + "Principal": {"client", "clientId", "ticket"}, + "Grant": {"id", "scopes", "purposes", "controller"}, + "Pending": {"hash", "ttlS"}, + "WebPushRow": {"id", "dev", "epoch", "endpoint", "ct", "auth"}, MsgBye: {"why", "purge"}, diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 512271f26..d169c8e0f 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -335,6 +335,20 @@ type Roster struct { // QR code. FTW Cloud's keys part opens an agent's session to it, and // trusts it only as signed here, by the home's key. Noise []byte `cbor:"noise"` + // WebPush holds browser farewells encrypted and signed by the box. + // The service never forwards these rows to a phone. + WebPush []WebPushRow `cbor:"webpush,omitempty"` +} + +// WebPushRow is a pre-encrypted box.unreachable notice for one browser. +// Epoch binds it to the current device grant. No push key leaves the box. +type WebPushRow struct { + ID string `cbor:"id"` + Device string `cbor:"dev"` + Epoch uint64 `cbor:"epoch"` + Endpoint string `cbor:"endpoint"` + CT []byte `cbor:"ct"` + Auth string `cbor:"auth"` } // Device is one paired phone. diff --git a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json index bf679d453..583c52cec 100644 --- a/go/protocol/cloudwire/testdata/cloud-quic-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-quic-vectors.json @@ -242,6 +242,11 @@ "name": "lp car tapped", "envelope": "a361625901ada7637365711264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7f20868666565646261636b8068757074696d654d731a00013c686a636f6e74726f6c52657619032e6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46563617249646b464e4b6962697466655530666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553190221696e657874456e644d73f669726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e57680069736f63536f757263656776656869636c656a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f6361724964656e7469666965644279637461706f636f6d6d616e646564526561736f6e64706c616e6174626c7063736967584089b4a6faa2af341cdc4da9ad0247aa2135f40e1aec94554cf1855392d22412cb2191b2a594084e4490faada200f68abbdcdd80b7b0bc3e7fb4ff65e5572f3fcf", "datagram": false + }, + { + "name": "roster browser notice", + "envelope": "a361625901e7ab637365711364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657260656e6f697365406677616c6c4d731b000001a130d7f5f0676465766963657381a66269646762726f77736572636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573816f6674772e6576656e74732e726561646770656e64696e6780677765627075736881a662637458806666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666626964736f70617175652d737562736372697074696f6e636465766762726f77736572646175746870766170696420626f782d7369676e65646565706f63680168656e64706f696e74781b68747470733a2f2f707573682e6578616d706c652f6f706171756568757074696d654d731a0001405069726f7374657253657105617466726f737465726373696758409c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9", + "datagram": false } ], "notes": { diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index 52a05b70a..93e72a987 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -311,6 +311,16 @@ "sig": "89b4a6faa2af341cdc4da9ad0247aa2135f40e1aec94554cf1855392d22412cb2191b2a594084e4490faada200f68abbdcdd80b7b0bc3e7fb4ff65e5572f3fcf", "envelope": "a361625901ada7637365711264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7f20868666565646261636b8068757074696d654d731a00013c686a636f6e74726f6c52657619032e6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46563617249646b464e4b6962697466655530666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553190221696e657874456e644d73f669726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e57680069736f63536f757263656776656869636c656a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f6361724964656e7469666965644279637461706f636f6d6d616e646564526561736f6e64706c616e6174626c7063736967584089b4a6faa2af341cdc4da9ad0247aa2135f40e1aec94554cf1855392d22412cb2191b2a594084e4490faada200f68abbdcdd80b7b0bc3e7fb4ff65e5572f3fcf", "frame": "0101000001fea361625901ada7637365711264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7f20868666565646261636b8068757074696d654d731a00013c686a636f6e74726f6c52657619032e6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46563617249646b464e4b6962697466655530666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553190221696e657874456e644d73f669726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e57680069736f63536f757263656776656869636c656a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f6361724964656e7469666965644279637461706f636f6d6d616e646564526561736f6e64706c616e6174626c7063736967584089b4a6faa2af341cdc4da9ad0247aa2135f40e1aec94554cf1855392d22412cb2191b2a594084e4490faada200f68abbdcdd80b7b0bc3e7fb4ff65e5572f3fcf" + }, + { + "name": "roster browser notice", + "signer": "box", + "t": "roster", + "body": "ab637365711364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657260656e6f697365406677616c6c4d731b000001a130d7f5f0676465766963657381a66269646762726f77736572636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573816f6674772e6576656e74732e726561646770656e64696e6780677765627075736881a662637458806666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666626964736f70617175652d737562736372697074696f6e636465766762726f77736572646175746870766170696420626f782d7369676e65646565706f63680168656e64706f696e74781b68747470733a2f2f707573682e6578616d706c652f6f706171756568757074696d654d731a0001405069726f7374657253657105", + "signedInputSha256": "31f3869339e89ade757686917fddee449040257f0da2c55058419624a7f55e99", + "sig": "9c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9", + "envelope": "a361625901e7ab637365711364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657260656e6f697365406677616c6c4d731b000001a130d7f5f0676465766963657381a66269646762726f77736572636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573816f6674772e6576656e74732e726561646770656e64696e6780677765627075736881a662637458806666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666626964736f70617175652d737562736372697074696f6e636465766762726f77736572646175746870766170696420626f782d7369676e65646565706f63680168656e64706f696e74781b68747470733a2f2f707573682e6578616d706c652f6f706171756568757074696d654d731a0001405069726f7374657253657105617466726f737465726373696758409c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9", + "frame": "01010000023ca361625901e7ab637365711364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657260656e6f697365406677616c6c4d731b000001a130d7f5f0676465766963657381a66269646762726f77736572636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573816f6674772e6576656e74732e726561646770656e64696e6780677765627075736881a662637458806666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666626964736f70617175652d737562736372697074696f6e636465766762726f77736572646175746870766170696420626f782d7369676e65646565706f63680168656e64706f696e74781b68747470733a2f2f707573682e6578616d706c652f6f706171756568757074696d654d731a0001405069726f7374657253657105617466726f737465726373696758409c84115970bee55473418d660236e759a1c818333cc1a1c0d7b5aba44d0eaa2f9fc08e617b72d788232ca0bd46e0d4602074c27222725d7c8d7e83c54ea340c9" } ] } diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index 88b4f8713..28bf436e2 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -263,6 +263,7 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { }}, Feedback: []Feedback{}, }}, + {"roster browser notice", "box", MsgRoster, Roster{Header: h(19, 82_000), RosterSeq: 5, TTLS: 86400, Devices: []Device{{ID: "browser", Key: bytes.Repeat([]byte{0x55}, 32), Role: "owner", Scopes: []string{protocol.ScopeEventsRead}, Epoch: 1}}, WebPush: []WebPushRow{{ID: "opaque-subscription", Device: "browser", Epoch: 1, Endpoint: "https://push.example/opaque", CT: bytes.Repeat([]byte{0x66}, 128), Auth: "vapid box-signed"}}}}, } } diff --git a/go/protocol/cloudwire/webpush.go b/go/protocol/cloudwire/webpush.go new file mode 100644 index 000000000..8c1e10059 --- /dev/null +++ b/go/protocol/cloudwire/webpush.go @@ -0,0 +1,27 @@ +package cloudwire + +import "errors" + +const MaxWebPushRows = 16 +const MaxWebPushBytes = 32 * 1024 + +// ValidateWebPush bounds the service-only part of a roster. The service also +// checks delivery addresses and the current device grant before it sends. +func ValidateWebPush(rows []WebPushRow) error { + bad := errors.New("cloud: invalid browser notice rows") + if len(rows) > MaxWebPushRows { + return bad + } + seen := map[string]bool{} + for _, r := range rows { + if r.ID == "" || len(r.ID) > 128 || seen[r.ID] || r.Device == "" || len(r.Device) > 128 || r.Epoch == 0 || len(r.Endpoint) > 2048 || len(r.Auth) > 512 || len(r.CT) == 0 || len(r.CT) > 1024 { + return bad + } + seen[r.ID] = true + } + b, err := Marshal(rows) + if err != nil || len(b) > MaxWebPushBytes { + return bad + } + return nil +} diff --git a/go/protocol/fleetwire/fleet.go b/go/protocol/fleetwire/fleet.go new file mode 100644 index 000000000..216be8fce --- /dev/null +++ b/go/protocol/fleetwire/fleet.go @@ -0,0 +1,119 @@ +// Package fleetwire names the anonymous daily report, without a home id. +package fleetwire + +import ( + "bytes" + "encoding/json" + "errors" + "regexp" + "slices" + "strings" +) + +const Schema = "ftw.fleet/1" +const StatsSchema = "ftw.fleet-stats/1" +const Meaning = "reports, not unique boxes" +const MaxBodyBytes = 4096 +const MaxDays = 90 +const MaxLabels = 128 +const MaxReports = 100_000 + +// Report carries only shared labels and buckets. Never add an identifier. +type Report struct { + Schema string `json:"schema"` + Version string `json:"ftw_version"` + Channel string `json:"channel"` + Drivers []string `json:"drivers"` + Battery string `json:"battery_kwh"` + Zone string `json:"price_zone"` + Age string `json:"install_age"` +} + +var release = regexp.MustCompile(`^v[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}(-beta\.[0-9]{1,4})?$`) +var driver = regexp.MustCompile(`^[a-z0-9_]{1,64}$`) +var zone = regexp.MustCompile(`^[A-Z0-9-]{2,16}$`) + +// Decode refuses extra fields, so a sender cannot add a household record. +func Decode(data []byte) (Report, error) { + bad := errors.New("fleet: invalid report") + var r Report + if len(data) > MaxBodyBytes { + return r, bad + } + var fields map[string]json.RawMessage + if json.Unmarshal(data, &fields) != nil || len(fields) != 7 || bytes.Equal(bytes.TrimSpace(fields["drivers"]), []byte("null")) { + return r, bad + } + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if d.Decode(&r) != nil || r.Schema != Schema || (r.Version != "unknown" && !release.MatchString(r.Version)) { + return r, bad + } + channel := "stable" + if r.Version == "unknown" { + channel = "unknown" + } else if strings.Contains(r.Version, "-beta.") { + channel = "beta" + } + if r.Channel != channel || len(r.Drivers) > 16 || !slices.Contains([]string{"none", "0-5", "5-15", "15-30", "30+"}, r.Battery) || + (r.Zone != "unknown" && !zone.MatchString(r.Zone)) || !slices.Contains([]string{"unknown", "0-1m", "1-6m", "6-12m", "1y+"}, r.Age) { + return r, bad + } + for i, name := range r.Drivers { + if !driver.MatchString(name) || (i > 0 && name <= r.Drivers[i-1]) { + return r, bad + } + } + return r, nil +} + +// Day is a day's totals. No individual report survives aggregation. +type Day struct { + Date string `json:"date"` + Reports int `json:"reports"` + Versions map[string]int `json:"ftw_versions"` + Channels map[string]int `json:"channels"` + Drivers map[string]int `json:"drivers"` + Battery map[string]int `json:"battery_kwh"` + Zones map[string]int `json:"price_zones"` + Age map[string]int `json:"install_age"` +} + +type View struct { + Schema string `json:"schema"` + Meaning string `json:"meaning"` + Days []Day `json:"days"` +} + +func NewDay(date string) Day { + return Day{Date: date, Versions: map[string]int{}, Channels: map[string]int{}, Drivers: map[string]int{}, + Battery: map[string]int{}, Zones: map[string]int{}, Age: map[string]int{}} +} + +// Add bounds both the count and the number of labels in a day. +func (d *Day) Add(r Report) bool { + if d.Reports < 0 || d.Reports >= MaxReports || d.Versions == nil || d.Channels == nil || d.Drivers == nil || d.Battery == nil || d.Zones == nil || d.Age == nil { + return false + } + d.Reports++ + inc(d.Versions, r.Version) + inc(d.Channels, r.Channel) + if len(r.Drivers) == 0 { + inc(d.Drivers, "none") + } else { + for _, name := range r.Drivers { + inc(d.Drivers, name) + } + } + inc(d.Battery, r.Battery) + inc(d.Zones, r.Zone) + inc(d.Age, r.Age) + return true +} + +func inc(counts map[string]int, label string) { + if _, ok := counts[label]; !ok && len(counts) >= MaxLabels-1 { + label = "other" + } + counts[label]++ +} diff --git a/go/protocol/fleetwire/fleet_test.go b/go/protocol/fleetwire/fleet_test.go new file mode 100644 index 000000000..568e72aee --- /dev/null +++ b/go/protocol/fleetwire/fleet_test.go @@ -0,0 +1,47 @@ +package fleetwire + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +const valid = `{"schema":"ftw.fleet/1","ftw_version":"v0.146.0-beta.1","channel":"beta","drivers":["pixii","pixii_pv"],"battery_kwh":"5-15","price_zone":"SE4","install_age":"1-6m"}` + +func TestOnlySharedLabelsAndBucketsAreAccepted(t *testing.T) { + if _, err := Decode([]byte(valid)); err != nil { + t.Fatal(err) + } + for _, bad := range []string{ + strings.Replace(valid, `"beta"`, `"stable"`, 1), + strings.Replace(valid, `"pixii","pixii_pv"`, `"pixii","pixii"`, 1), + strings.Replace(valid, `"pixii","pixii_pv"`, `"pixii_pv","pixii"`, 1), + strings.Replace(valid, `["pixii","pixii_pv"]`, `null`, 1), + strings.Replace(valid, `"SE4"`, `"myhouse"`, 1), + strings.Replace(valid, `"5-15"`, `"12.456"`, 1), + strings.Replace(valid, `"v0.146.0-beta.1"`, `"abcdef-dirty"`, 1), + strings.TrimSuffix(valid, "}") + `,"home":"secret"}`, + valid + valid, `[]`, `null`, strings.Repeat(" ", MaxBodyBytes+1) + valid, + } { + if _, err := Decode([]byte(bad)); err == nil { + t.Fatalf("accepted %s", bad) + } + } +} + +func TestCountersHaveFixedLimitsAndNoReportSurvives(t *testing.T) { + d := NewDay("2026-10-17") + for i := 0; i < MaxReports; i++ { + if !d.Add(Report{Version: fmt.Sprint(i), Drivers: []string{fmt.Sprint(i)}, Channel: "beta", Battery: "5-15", Zone: "SE4", Age: "1-6m"}) { + t.Fatal(i) + } + } + if d.Add(Report{}) || len(d.Versions) > MaxLabels || len(d.Drivers) > MaxLabels || d.Reports != MaxReports { + t.Fatal(d.Reports, len(d.Versions)) + } + data, _ := json.Marshal(d) + if strings.Contains(string(data), `"home"`) { + t.Fatal("identifier persisted") + } +} From 49047776f62bc6701ad9dfea27dbe2c12ef35d04 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 09:06:27 +0200 Subject: [PATCH 2/2] fix(cloud): move the shipped fleet endpoint to Cloud Signed-off-by: Fredrik Ahlgren --- go/internal/config/config.go | 2 +- go/internal/config/fleet_ping_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go/internal/config/config.go b/go/internal/config/config.go index f468adbf8..378643868 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -274,7 +274,7 @@ func (f *FleetPing) On() bool { // this address after FTW moved the collector, and the aggregate would // quietly lose every box that had ever saved its settings. func (f *FleetPing) Resolved() string { - if f == nil || f.Endpoint == "" || strings.TrimSpace(f.Endpoint) == "https://relay.ftw.energy/fleet/report" { + if f == nil || f.Endpoint == "" || strings.TrimSpace(f.Endpoint) == "https://relay.ftw.energy/fleet" { return DefaultFleetPingEndpoint } return f.Endpoint diff --git a/go/internal/config/fleet_ping_test.go b/go/internal/config/fleet_ping_test.go index 0a209e160..3f4dd1c47 100644 --- a/go/internal/config/fleet_ping_test.go +++ b/go/internal/config/fleet_ping_test.go @@ -131,7 +131,7 @@ func TestFleetPingSwitchNeedsNoRestartButTheEndpointDoes(t *testing.T) { } func TestAnExplicitRetiredFleetCollectorResolvesToCloud(t *testing.T) { - f := &FleetPing{Enabled: true, Endpoint: "https://relay.ftw.energy/fleet/report"} + f := &FleetPing{Enabled: true, Endpoint: "https://relay.ftw.energy/fleet"} if f.Resolved() != DefaultFleetPingEndpoint { t.Fatal(f.Resolved()) }