From 077fe394ed023ec57a6bcde0a4c31b1e4d72e4bf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:25:11 +0200 Subject: [PATCH 1/7] feat: widgets read /v1/now with a token bound to a roster entry A device asks for its extension token over its app session with token.get. GET /v1/now with that token returns the newest live, prices and hb as the box signed them, and nothing else. The registry keeps the token's SHA-256, never the token, bound to one roster entry. A read reaches the home only through store.For with a new Token principal, so the token stops working when the roster drops the device, moves its epoch or runs out, an owner suspends the device, or the home is purged. Moves the go/protocol pin to srcfl/ftw master 7c72a5b3, which holds cloudwire's token.get and token types. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- README.md | 26 ++- cmd/ftwcloudd/main.go | 10 +- deploy/README.md | 8 +- go.mod | 2 +- go.sum | 4 +- internal/appsession/appsession.go | 62 +++++- internal/appsession/token_test.go | 127 +++++++++++ internal/registry/registry.go | 9 +- internal/registry/token.go | 101 +++++++++ internal/registry/token_test.go | 71 +++++++ internal/server/server.go | 25 ++- internal/server/server_test.go | 4 +- internal/store/session.go | 2 +- internal/store/store.go | 69 ++++-- internal/store/store_test.go | 85 ++++++++ internal/token/token.go | 160 ++++++++++++++ internal/token/token_test.go | 339 ++++++++++++++++++++++++++++++ 18 files changed, 1062 insertions(+), 46 deletions(-) create mode 100644 internal/appsession/token_test.go create mode 100644 internal/registry/token.go create mode 100644 internal/registry/token_test.go create mode 100644 internal/token/token.go create mode 100644 internal/token/token_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0634c78..ebc8527 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,8 @@ maintains it. - **The home id comes from the key that signed hello** on this connection, never from a body field. Every later box message must verify with that key. - **No readings on disk.** Memory holds readings; SQLite holds only the home - id, the box key, the signed roster, owners' suspensions and tombstones. - Nothing logs addresses. + id, the box key, the signed roster, owners' suspensions, tombstones, APNs + registrations and the hashes of extension tokens. Nothing logs addresses. - **A phone can only narrow.** On `/v1/app` it can say whether it is looking, and an owner can suspend a roster device. The service admits a phone by the key its Noise handshake proved, and only if the box's roster lists it. diff --git a/README.md b/README.md index 46a327f..9da9a92 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ both in `srcfl/ftw`. Both are proposed. The service runs at `cloud.ftw.energy` ([deploy/README.md](deploy/README.md)), but no box sends to it yet. -## What works today (C2, C3, C4) +## What works today - A box with FTW Cloud on keeps one WebSocket at `/v1/box`. The service checks that the box signed `hello` over this TLS session's exporter value, @@ -21,8 +21,9 @@ to it yet. - Each home's newest message of each class lives in memory, as the exact bytes the box sent. Readings are dropped an hour after the box goes quiet and are never written to disk. -- The registry (SQLite) holds the home id, the box key, the signed roster and - owners' suspensions. A box that turns FTW Cloud off gets its home deleted +- The registry (SQLite) holds the home id, the box key, the signed roster, + owners' suspensions, APNs registrations and the hashes of extension + tokens. A box that turns FTW Cloud off gets its home deleted and a signed receipt. - `/source` serves the source of the running build, as AGPL-3.0 asks, and names its commit and image digest. @@ -48,27 +49,36 @@ to it yet. device, and go when the roster drops them or Apple says they are gone. Apple's key comes from Parameter Store ([deploy/apns.md](deploy/apns.md)). +- **Widget token.** A device asks for its extension token over its app + session with `token.get`. A widget reads `GET /v1/now` with it: the newest + `live`, `prices` and `hb` as the box signed them, and nothing else. The + service keeps the token's SHA-256, never the token. A device holds one + token, valid for 7 days; a new one replaces the old. It stops reading when + the roster drops the device, moves its epoch or runs out, an owner + suspends the device, or the home is purged. -Not built yet, with a marked place in the code: +Not built yet: | Step | What | Where | |---|---|---| -| C5 | Widget token reads, `/v1/now`, tiles | `internal/server` | +| C6 | Tiles, widget push | `internal/server`, `internal/push` | | C7–C8 | Statistics | not started | ## How it holds a home ``` box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory) ◀── store.For(principal) ◀── /v1/app ◀──Noise IK── phone - └────▶ registry (SQLite: key, roster, tombstones, suspensions) + │ ◀── /v1/now ◀──token──── widget + └────▶ registry (SQLite: key, roster, tombstones, suspensions, APNs, token hashes) ``` - **The home id is a hash of the box's key.** It comes from the key that signed `hello`, never from a field anyone sends. A second box cannot claim a home that is not its own. - **One way in.** `store.For(principal)` is the only access to a home: a - `Box` principal may write its own home, and a `Device` principal may - read the classes its role and the owner's grant both allow. There is no + `Box` principal may write its own home, a `Device` principal may + read the classes its role and the owner's grant both allow, and a `Token` + principal reads only `live`, `prices` and `hb` within that same scope. There is no listing and no principal for staff; `TestNoRouteReadsAHome` checks every route. - **A phone can narrow and nothing else.** It can say whether it is looking, diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index f0d89f7..edb78eb 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -37,6 +37,7 @@ import ( "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/server" "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw-cloud/internal/token" "github.com/srcfl/ftw-cloud/internal/uplink" "github.com/srcfl/ftw/go/protocol/appwire" "github.com/srcfl/ftw/go/protocol/cloudwire" @@ -97,7 +98,8 @@ func serve(args []string) error { st := store.New(nil) st.UseSuspensions(reg.Suspended) up := uplink.New(st, reg, key, log) - srv := &server.Server{Store: st, Registry: reg, Uplink: up, Log: log} + tokens := token.New(st, reg, log) + srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log} // Alerts: the box's signed events to its phones through Apple, and // box.unreachable when a box is away. Without a key the service keeps @@ -117,7 +119,10 @@ func serve(args []string) error { } pushSvc := push.New(reg, sender, log) up.OnEvent = pushSvc.Event - up.OnRoster = pushSvc.Roster + up.OnRoster = func(home string, r *cloudwire.Roster) { + pushSvc.Roster(home, r) + tokens.Roster(home, r) + } up.OnGone = pushSvc.BoxGone // Phones register their tokens over their app session, so without a @@ -125,6 +130,7 @@ func serve(args []string) error { if noiseOn { srv.App = appsession.New(st, reg, up, noise, log) srv.App.Push = pushSvc + srv.App.Tokens = tokens } app := srv.App up.OnConnect = func(home string) { diff --git a/deploy/README.md b/deploy/README.md index 9d43c01..f5db043 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -153,7 +153,8 @@ curl -sI https://cloud.ftw.energy/source `ok` means DNS, the certificate and the service all work. `/source` returns the source archive, and its headers name the commit (`X-Source-Revision`) and the image digest (`X-Source-Image`), which should match what CI built. -`/v1/app` and `/v1/now` answer 501 until C3 and C5. +`/v1/app` answers 501 on a host without the Noise key, and `/v1/now` answers +401 to a request without a valid token. ## Cost @@ -198,6 +199,7 @@ Cloud data; the images on `ghcr.io` hold only code. ## Not here yet The APNs key ([apns.md](apns.md)) and the Noise key phones dial -([noise.md](noise.md)) come from Parameter Store. Widget tokens (C5) and -statistics (C7–C8) add secrets, egress and storage when they land. The erase test, canary homes +([noise.md](noise.md)) come from Parameter Store. Widget tokens need no +secret: the registry holds their hashes. Statistics (C7–C8) add secrets, +egress and storage when they land. The erase test, canary homes and the external heartbeat that pages Sourceful belong with the first homes. diff --git a/go.mod b/go.mod index f4567f9..e48952a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 - github.com/srcfl/ftw/go/protocol v0.0.0-20261013083029-b1b34697d7ab + github.com/srcfl/ftw/go/protocol v0.0.0-20261014110822-7c72a5b33aa0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 modernc.org/sqlite v1.59.0 diff --git a/go.sum b/go.sum index d680714..be93d77 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261013083029-b1b34697d7ab h1:InL7LLEvfapkns81aQJCIQq0PUEPQ8JRzMtSEaMqWNo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261013083029-b1b34697d7ab/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261014110822-7c72a5b33aa0 h1:4p9TFWdkIk5y2fK3/9FOeUV9AukTjIBd7jQZDLG8Qx8= +github.com/srcfl/ftw/go/protocol v0.0.0-20261014110822-7c72a5b33aa0/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= diff --git a/internal/appsession/appsession.go b/internal/appsession/appsession.go index 8835240..45b24f9 100644 --- a/internal/appsession/appsession.go +++ b/internal/appsession/appsession.go @@ -9,9 +9,9 @@ // Message 2 carries the snapshot; after it every envelope the box sends that // the device may read goes out unchanged, one frame per Noise message. // -// A phone may ask for faster updates, and an owner may suspend a device. That -// is all it can do here: commands go to the box over the relay, never -// through FTW Cloud. +// A phone may ask for faster updates, ask for its extension token, register +// for alerts, and an owner may suspend a device. That is all it can do here: +// commands go to the box over the relay, never through FTW Cloud. package appsession import ( @@ -74,6 +74,9 @@ type Server struct { // Push keeps the phones' APNs tokens. Nil ignores push.register and // push.unregister. Set before serving. Push Pusher + // Tokens issues extension tokens. Nil ignores token.get. Set before + // serving. + Tokens Tokens mu sync.Mutex looking map[string]*look @@ -86,6 +89,13 @@ type Pusher interface { Unregister(home string, dev cloudwire.Device, r cloudwire.PushUnregister) error } +// Tokens gives a device its extension token. token.Service is one. dev +// comes from store.For, never from the phone's message. +type Tokens interface { + Issue(home string, dev cloudwire.Device) (cloudwire.Token, error) + Drop(home, deviceID string) error +} + // look is one home's phones that can see it, and the loop that keeps the // box's watch in force while there is at least one. type look struct { @@ -183,7 +193,8 @@ func (s *Server) serve(conn *websocket.Conn, home string) (int, error) { if err != nil { return websocket.CloseInternalServerErr, err } - c := &phoneConn{s: s, conn: conn, dev: dev, home: home, tokens: rateBurst, refilled: s.now()} + c := &phoneConn{s: s, conn: conn, dev: dev, home: home, tokens: rateBurst, refilled: s.now(), + replies: make(chan []byte, 1), quit: make(chan struct{})} if err := c.write(msg2); err != nil { return 0, err } @@ -297,6 +308,12 @@ type phoneConn struct { tokens float64 refilled time.Time + + // replies carries the service's answers from the read loop to the + // session's goroutine, the only one that writes; quit closes when that + // goroutine is done. + replies chan []byte + quit chan struct{} } func (c *phoneConn) setLooking(on bool) { @@ -349,6 +366,7 @@ func (c *phoneConn) forward(env []byte) error { // run forwards until the store ends the session, the phone leaves or breaks // the protocol. func (c *phoneConn) run(sess *store.Session) (int, error) { + defer close(c.quit) type result struct { code int err error @@ -366,6 +384,10 @@ func (c *phoneConn) run(sess *store.Session) (int, error) { if err := c.forward(env); err != nil { return 0, err } + case env := <-c.replies: + if err := c.forward(env); err != nil { + return 0, err + } case <-sess.Done(): return sess.Code(), nil case r := <-read: @@ -452,10 +474,35 @@ func (c *phoneConn) apply(frame []byte) (int, error) { if c.s.Push != nil { return 0, pushed(c.s.Push.Unregister(c.home, c.dev.Device(), r)) } + case cloudwire.MsgTokenGet: + if c.s.Tokens == nil { + return 0, nil + } + tok, err := c.s.Tokens.Issue(c.home, c.dev.Device()) + if err != nil { + return 0, err + } + return 0, c.say(cloudwire.MsgToken, tok) } return 0, nil } +// say hands an answer to the session's goroutine to send. +func (c *phoneConn) say(t string, body any) error { + env, err := cloudwire.Unsigned(t, body) + if err != nil { + return err + } + select { + case c.replies <- env: + return nil + case <-c.quit: + return errClosed + } +} + +var errClosed = errors.New("appsession: the session ended") + // pushed lets the session go on after a registration the device may not // make, such as a viewer's: it gets no alerts, and nothing else changes. func pushed(err error) error { @@ -486,6 +533,13 @@ func (c *phoneConn) suspend(deviceID string, on bool) error { if err := c.s.registry.Suspend(c.home, d.ID, d.Epoch, c.s.now()); err != nil { return err } + // A suspended device's token dies; lifting the suspension does not + // bring it back, the app asks for a new one. + if c.s.Tokens != nil { + if err := c.s.Tokens.Drop(c.home, d.ID); err != nil { + return err + } + } c.dev.Recheck() return nil } diff --git a/internal/appsession/token_test.go b/internal/appsession/token_test.go new file mode 100644 index 0000000..817e90e --- /dev/null +++ b/internal/appsession/token_test.go @@ -0,0 +1,127 @@ +package appsession + +import ( + "bytes" + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/token" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// tokens gives the harness a token service and /v1/now behind TLS, and +// returns a reader that answers with the status a token gets there. +func (h *harness) tokens(t *testing.T) func(tok string) int { + t.Helper() + svc := token.New(h.store, h.reg, nil) + h.app.Tokens = svc + srv := httptest.NewUnstartedServer(svc) + srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} + srv.StartTLS() + t.Cleanup(srv.Close) + return func(tok string) int { + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/now", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return resp.StatusCode + } +} + +// getToken asks over the session and reads the answer, skipping any box +// envelope that arrives first. +func (p *phone) getToken() cloudwire.Token { + p.t.Helper() + p.send(cloudwire.MsgTokenGet, struct{}{}) + for { + raw, code := p.next(2 * time.Second) + if code != 0 { + p.t.Fatalf("closed with %d", code) + } + env, err := cloudwire.DecodeEnvelope(raw) + if err != nil { + p.t.Fatal(err) + } + if env.T != cloudwire.MsgToken { + continue + } + if len(env.Sig) != 0 { + p.t.Fatal("the service signed its token answer") + } + var tok cloudwire.Token + if err := cloudwire.Unmarshal(env.B, &tok); err != nil { + p.t.Fatal(err) + } + return tok + } +} + +// A device asks for its token over its own session. It reads /v1/now, the +// next one replaces it, and the session goes on. +func TestADeviceGetsItsTokenOverItsSession(t *testing.T) { + h := newHarness(t) + read := h.tokens(t) + h.live(true) + viewer := h.mustDial(dialOpts{key: h.viewer}) + first := viewer.getToken() + if first.ExpiresInS != int64(token.Life/time.Second) { + t.Fatalf("expires in %d s", first.ExpiresInS) + } + if code := read(first.Token); code != http.StatusOK { + t.Fatalf("the token read %d", code) + } + second := viewer.getToken() + if code := read(first.Token); code != http.StatusUnauthorized { + t.Fatalf("the replaced token read %d", code) + } + if code := read(second.Token); code != http.StatusOK { + t.Fatalf("the new token read %d", code) + } + next := h.live(false) + if got, code := viewer.next(time.Second); code != 0 || !bytes.Equal(got, next) { + t.Fatalf("the session did not go on: code %d", code) + } +} + +// An owner's suspension kills the device's token; lifting it does not bring +// the token back. +func TestASuspensionKillsTheToken(t *testing.T) { + h := newHarness(t) + read := h.tokens(t) + h.live(true) + owner := h.mustDial(dialOpts{key: h.owner}) + viewer := h.mustDial(dialOpts{key: h.viewer}) + tok := viewer.getToken() + owner.send(cloudwire.MsgSuspend, cloudwire.Suspend{DeviceID: "viewer"}) + if code := viewer.closed(2 * time.Second); code != cloudwire.CloseDenied { + t.Fatalf("the suspended viewer closed with %d", code) + } + if code := read(tok.Token); code != http.StatusUnauthorized { + t.Fatalf("a suspended device's token read %d", code) + } + owner.send(cloudwire.MsgUnsuspend, cloudwire.Suspend{DeviceID: "viewer"}) + waitFor(t, "the suspension to lift", func() bool { return !h.reg.Suspended(h.home, "viewer", 1) }) + if code := read(tok.Token); code != http.StatusUnauthorized { + t.Fatalf("lifting the suspension brought the token back: %d", code) + } +} + +// Without a token service, token.get is ignored and the session goes on. +func TestWithoutTokensTokenGetIsIgnored(t *testing.T) { + h := newHarness(t) + h.live(true) + owner := h.mustDial(dialOpts{key: h.owner}) + owner.send(cloudwire.MsgTokenGet, struct{}{}) + next := h.live(false) + if got, code := owner.next(time.Second); code != 0 || !bytes.Equal(got, next) { + t.Fatalf("the session did not go on: code %d", code) + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index a2f1291..056f8db 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -1,6 +1,7 @@ // Package registry is the service's only disk state: which homes exist, the -// box key each was claimed with, and the roster the box last signed. It holds -// no readings. Deleted rows are overwritten (secure_delete), so a purge does +// box key each was claimed with, the roster the box last signed, owners' +// suspensions, APNs registrations and the hashes of extension tokens. It +// holds no readings. Deleted rows are overwritten (secure_delete), so a purge does // not leave a home's roster in free pages. package registry @@ -79,6 +80,10 @@ CREATE TABLE IF NOT EXISTS suspensions ( _ = db.Close() return nil, fmt.Errorf("registry: schema: %w", err) } + if err := createTokenTable(db); err != nil { + _ = db.Close() + return nil, fmt.Errorf("registry: schema: %w", err) + } return &Registry{db: db}, nil } diff --git a/internal/registry/token.go b/internal/registry/token.go new file mode 100644 index 0000000..716dfb6 --- /dev/null +++ b/internal/registry/token.go @@ -0,0 +1,101 @@ +package registry + +import ( + "database/sql" + "errors" + "time" +) + +// Extension tokens. Each row binds the SHA-256 of one token to one roster +// entry (device id and epoch) of one home; the token itself is never stored. +// A device holds one token: a new one replaces the old. A row goes when its +// home goes (the foreign key cascades on purge and expiry), when a roster +// drops the device or moves its epoch, when an owner suspends the device, +// and when it expires. + +// TokenRow is one token's binding. +type TokenRow struct { + Home string + DeviceID string + Epoch uint64 + Expires time.Time +} + +func createTokenTable(db *sql.DB) error { + _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS tokens ( + home TEXT NOT NULL REFERENCES homes(home) ON DELETE CASCADE, + device_id TEXT NOT NULL, + epoch INTEGER NOT NULL, + hash BLOB NOT NULL UNIQUE, + expires_ms INTEGER NOT NULL, + PRIMARY KEY (home, device_id) +);`) + return err +} + +// TokenIssue binds a token's hash to a roster entry, in place of the +// device's old token. +func (r *Registry) TokenIssue(row TokenRow, hash []byte) error { + _, err := r.db.Exec(`INSERT INTO tokens (home, device_id, epoch, hash, expires_ms) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (home, device_id) DO UPDATE SET epoch = excluded.epoch, hash = excluded.hash, + expires_ms = excluded.expires_ms`, + row.Home, row.DeviceID, int64(row.Epoch), hash, row.Expires.UnixMilli()) + return err +} + +// TokenLookup returns the binding of a token's hash, if it has not expired. +func (r *Registry) TokenLookup(hash []byte, now time.Time) (TokenRow, bool, error) { + var row TokenRow + var epoch, ms int64 + err := r.db.QueryRow(`SELECT home, device_id, epoch, expires_ms FROM tokens WHERE hash = ? AND expires_ms > ?`, + hash, now.UnixMilli()).Scan(&row.Home, &row.DeviceID, &epoch, &ms) + if errors.Is(err, sql.ErrNoRows) { + return TokenRow{}, false, nil + } + if err != nil { + return TokenRow{}, false, err + } + row.Epoch, row.Expires = uint64(epoch), time.UnixMilli(ms) + return row, true, nil +} + +// TokenDrop forgets a device's token, as when an owner suspends it. +func (r *Registry) TokenDrop(home, deviceID string) error { + _, err := r.db.Exec(`DELETE FROM tokens WHERE home = ? AND device_id = ?`, home, deviceID) + return err +} + +// TokenPrune keeps only the tokens whose device keep allows at its epoch, as +// a new roster says. +func (r *Registry) TokenPrune(home string, keep func(deviceID string, epoch uint64) bool) error { + rows, err := r.db.Query(`SELECT device_id, epoch FROM tokens WHERE home = ?`, home) + if err != nil { + return err + } + var gone []string + for rows.Next() { + var id string + var epoch int64 + if err := rows.Scan(&id, &epoch); err != nil { + _ = rows.Close() + return err + } + if !keep(id, uint64(epoch)) { + gone = append(gone, id) + } + } + _ = rows.Close() + for _, id := range gone { + if err := r.TokenDrop(home, id); err != nil { + return err + } + } + return nil +} + +// TokenExpire forgets the tokens that have expired. +func (r *Registry) TokenExpire(now time.Time) error { + _, err := r.db.Exec(`DELETE FROM tokens WHERE expires_ms <= ?`, now.UnixMilli()) + return err +} diff --git a/internal/registry/token_test.go b/internal/registry/token_test.go new file mode 100644 index 0000000..0508c21 --- /dev/null +++ b/internal/registry/token_test.go @@ -0,0 +1,71 @@ +package registry + +import ( + "testing" + "time" +) + +// A device holds one token; a new one replaces the old, and only an unexpired +// hash is found. +func TestADeviceHoldsOneToken(t *testing.T) { + r := open(t) + now := time.Unix(1_791_820_800, 0) + _ = r.Claim("h1", []byte("pk"), now) + row := TokenRow{Home: "h1", DeviceID: "phone", Epoch: 2, Expires: now.Add(time.Hour)} + if err := r.TokenIssue(row, []byte("first")); err != nil { + t.Fatal(err) + } + if err := r.TokenIssue(row, []byte("second")); err != nil { + t.Fatal(err) + } + if _, ok, _ := r.TokenLookup([]byte("first"), now); ok { + t.Fatal("the replaced token still reads") + } + got, ok, err := r.TokenLookup([]byte("second"), now) + if err != nil || !ok || got.Home != "h1" || got.DeviceID != "phone" || got.Epoch != 2 { + t.Fatalf("lookup: %+v %v %v", got, ok, err) + } + if _, ok, _ := r.TokenLookup([]byte("second"), now.Add(time.Hour)); ok { + t.Fatal("an expired token was found") + } + if err := r.TokenExpire(now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if _, ok, _ := r.TokenLookup([]byte("second"), now); ok { + t.Fatal("upkeep left an expired token") + } +} + +// A roster that drops a device or moves its epoch drops its token, a +// suspension drops it, and a purge takes every token of the home. +func TestTokensGoWithTheirRosterEntry(t *testing.T) { + r := open(t) + now := time.Unix(1_791_820_800, 0) + _ = r.Claim("h1", []byte("pk"), now) + issue := func(id string, epoch uint64) { + if err := r.TokenIssue(TokenRow{Home: "h1", DeviceID: id, Epoch: epoch, Expires: now.Add(time.Hour)}, []byte(id)); err != nil { + t.Fatal(err) + } + } + found := func(id string) bool { + _, ok, _ := r.TokenLookup([]byte(id), now) + return ok + } + issue("kept", 1) + issue("moved", 1) + issue("dropped", 1) + issue("suspended", 1) + keep := map[string]uint64{"kept": 1, "moved": 2, "suspended": 1} + if err := r.TokenPrune("h1", func(id string, epoch uint64) bool { e, ok := keep[id]; return ok && e == epoch }); err != nil { + t.Fatal(err) + } + if !found("kept") || found("moved") || found("dropped") { + t.Fatal("the prune kept the wrong tokens") + } + if err := r.TokenDrop("h1", "suspended"); err != nil || found("suspended") { + t.Fatal("a dropped token still reads") + } + if err := r.Purge("h1", nil, now); err != nil || found("kept") { + t.Fatal("a purge left a token") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 24a8230..61cf12f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,12 +3,12 @@ // // /v1/box a box's uplink (package uplink) // /v1/app a phone's Noise session to the service (package appsession) -// /v1/now an extension's token read: C5, not built yet +// /v1/now a widget's token read (package token) // /source the source of the running service, as AGPL-3.0 asks // /healthz liveness, and nothing about any home // -// There is no route for staff. Reading a home needs its box's key or a -// phone in its roster. +// There is no route for staff. Reading a home needs its box's key, a phone in +// its roster, or a token bound to one. package server import ( @@ -22,6 +22,7 @@ import ( "github.com/srcfl/ftw-cloud/internal/appsession" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw-cloud/internal/token" "github.com/srcfl/ftw-cloud/internal/uplink" ) @@ -42,7 +43,10 @@ type Server struct { Uplink *uplink.Uplink // App is nil on a host without a Noise key; /v1/app then says so. App *appsession.Server - Log *slog.Logger + // Tokens answers /v1/now. Tokens come only over the app session, so + // without a Noise key every token read is refused. + Tokens *token.Service + Log *slog.Logger } // Handler is the whole HTTP surface. @@ -54,9 +58,11 @@ func (s *Server) Handler() http.Handler { } else { mux.HandleFunc("/v1/app", notYet) } - // C5: widgets read the newest live, prices and hb here with a 7-day - // token bound to one roster entry. - mux.HandleFunc("/v1/now", notYet) + if s.Tokens != nil { + mux.Handle("/v1/now", s.Tokens) + } else { + mux.HandleFunc("/v1/now", notYet) + } mux.HandleFunc("/source", source) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") @@ -130,7 +136,7 @@ func revision() string { // Upkeep applies the retention rules until ctx ends: readings go an hour // after the box does, homes without a roster go after 24 hours or 90 days, -// and old tombstones go. +// expired tokens go, and old tombstones go. func (s *Server) Upkeep(ctx context.Context, every time.Duration) { t := time.NewTicker(every) defer t.Stop() @@ -140,6 +146,9 @@ func (s *Server) Upkeep(ctx context.Context, every time.Duration) { return case <-t.C: s.Store.Sweep() + if err := s.Registry.TokenExpire(time.Now()); err != nil { + s.Log.Warn("token upkeep failed", "err", err) + } gone, err := s.Registry.Expire(time.Now()) if err != nil { s.Log.Warn("registry upkeep failed", "err", err) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a1c13f6..f51b6b0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -16,6 +16,7 @@ import ( "github.com/srcfl/ftw-cloud/internal/appsession" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw-cloud/internal/token" "github.com/srcfl/ftw-cloud/internal/uplink" "github.com/srcfl/ftw/go/protocol/appwire" "github.com/srcfl/ftw/go/protocol/cloudwire" @@ -33,7 +34,8 @@ func newServer(t *testing.T) (*Server, string) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) up := uplink.New(st, reg, key, log) noise, _ := appwire.GenerateKeyPair() - s := &Server{Store: st, Registry: reg, Uplink: up, App: appsession.New(st, reg, up, noise, log), Log: log} + s := &Server{Store: st, Registry: reg, Uplink: up, App: appsession.New(st, reg, up, noise, log), + Tokens: token.New(st, reg, log), Log: log} // A home with readings in it, held the way a box's uplink holds them. const home = "canaryhomecanaryhomecanary" diff --git a/internal/store/session.go b/internal/store/session.go index 7398408..8b8f657 100644 --- a/internal/store/session.go +++ b/internal/store/session.go @@ -49,7 +49,7 @@ func (x *Handle) Open() (Snapshot, *Session, error) { defer x.h.mu.Unlock() // The access checked in For may have changed since: check it again // under the lock that roster changes take. - if _, _, ok := x.s.access(x.id, x.h, x.device.Key); !ok { + if _, _, ok := x.s.access(x.id, x.h, byKey(x.device.Key)); !ok { return Snapshot{}, nil, ErrDenied } for sess := range x.h.sessions { diff --git a/internal/store/store.go b/internal/store/store.go index 1d46fff..8e42d49 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2,9 +2,10 @@ // disk: a restart empties it, and boxes fill it again when they reconnect. // // The only way to a home is For, with a principal that proves whose home it -// is: the box, by the signed hello on its own connection, or a phone in the -// home's roster, by its Noise handshake. There is no listing, no lookup by -// anything else, and no principal for staff. One bug in a second access path +// is: the box, by the signed hello on its own connection; a phone in the +// home's roster, by its Noise handshake; or an extension token the registry +// bound to a roster entry. There is no listing, no lookup by anything else, +// and no principal for staff. One bug in a second access path // would expose every home at once, so there is no second access path. package store @@ -31,6 +32,9 @@ const ( Box Kind = iota + 1 // Device is a phone in the home's roster, proven by its Noise static key. Device + // Token is a widget or an extension, proven by a token the registry + // bound to one roster entry. It reads only Now. + Token ) // Principal is who is asking for a home. @@ -39,6 +43,10 @@ type Principal struct { Home string // DeviceKey is the phone's X25519 static key, for Device. DeviceKey []byte + // DeviceID and Epoch are the roster entry a token is bound to, for + // Token. + DeviceID string + Epoch uint64 } // ErrDenied is every refusal. It does not say whether the home exists. @@ -107,7 +115,8 @@ type Handle struct { // For returns the principal's access to its home, or ErrDenied. A box may // create its home; a device reaches only a home whose current roster lists -// its key, and only while no owner has suspended it. +// its key, and only while no owner has suspended it. A token reaches it by +// the same rule, through the roster entry it is bound to at that epoch. func (s *Store) For(p Principal) (*Handle, error) { s.mu.Lock() h := s.homes[p.Home] @@ -125,24 +134,39 @@ func (s *Store) For(p Principal) (*Handle, error) { case Device: h.mu.Lock() defer h.mu.Unlock() - dev, scopes, ok := s.access(p.Home, h, p.DeviceKey) + dev, scopes, ok := s.access(p.Home, h, byKey(p.DeviceKey)) if !ok { return nil, ErrDenied } return &Handle{s: s, id: p.Home, h: h, kind: Device, device: dev, scopes: scopes}, nil + case Token: + h.mu.Lock() + defer h.mu.Unlock() + dev, scopes, ok := s.access(p.Home, h, func(d cloudwire.Device) bool { + return p.DeviceID != "" && d.ID == p.DeviceID && d.Epoch == p.Epoch + }) + if !ok { + return nil, ErrDenied + } + return &Handle{s: s, id: p.Home, h: h, kind: Token, device: dev, scopes: scopes}, nil } return nil, ErrDenied } +// byKey matches the roster entry with a device's Noise static key. +func byKey(key []byte) func(cloudwire.Device) bool { + return func(d cloudwire.Device) bool { return len(key) == 32 && bytes.Equal(d.Key, key) } +} + // access is the one admission rule for a device: a roster in force that -// lists its key, and no suspension. The caller holds h.mu. -func (s *Store) access(id string, h *home, key []byte) (cloudwire.Device, []string, bool) { +// lists it, and no suspension. The caller holds h.mu. +func (s *Store) access(id string, h *home, match func(cloudwire.Device) bool) (cloudwire.Device, []string, bool) { r := h.roster - if r == nil || len(key) != 32 || s.now().After(h.rosterAt.Add(time.Duration(r.TTLS)*time.Second)) { + if r == nil || s.now().After(h.rosterAt.Add(time.Duration(r.TTLS)*time.Second)) { return cloudwire.Device{}, nil, false } for _, d := range r.Devices { - if !bytes.Equal(d.Key, key) { + if !match(d) { continue } if s.suspended != nil && s.suspended(id, d.ID, d.Epoch) { @@ -242,7 +266,7 @@ func (x *Handle) Recheck() { func (x *Handle) recheckLocked() { for sess := range x.h.sessions { - dev, scopes, ok := x.s.access(x.id, x.h, sess.x.device.Key) + dev, scopes, ok := x.s.access(x.id, x.h, byKey(sess.x.device.Key)) switch { case !ok: sess.endLocked(cloudwire.CloseDenied) @@ -292,14 +316,19 @@ func (x *Handle) Snapshot() (Snapshot, error) { return x.snapshotLocked(), nil } -func (x *Handle) snapshotLocked() Snapshot { +func (x *Handle) snapshotLocked() Snapshot { return x.viewLocked(nil) } + +// viewLocked is the newest envelope of each class the device may read, of +// only those classes when only is not nil, and the newest live that carried +// sources, in rising seq. The caller holds the home's lock. +func (x *Handle) viewLocked(only []string) Snapshot { out := Snapshot{LastSeenMs: x.h.lastSeen.UnixMilli(), Online: x.h.online} if !x.h.online && x.s.now().Sub(x.h.lastSeen) > ReadingsKeptFor { return out } var held []Held for t, e := range x.h.latest { - if x.mayRead(t) { + if x.mayRead(t) && (only == nil || slices.Contains(only, t)) { held = append(held, e) } } @@ -313,6 +342,22 @@ func (x *Handle) snapshotLocked() Snapshot { return out } +// NowClasses are what a token reads: the newest live, prices and hb, and the +// newest live that carried sources. Never lp, plan, event, tiles or health. +var NowClasses = []string{cloudwire.MsgLive, cloudwire.MsgPrices, cloudwire.MsgHB} + +// Now is what a widget reads with its token: the newest envelope of each of +// NowClasses the device may read, and the newest live that carried sources, +// in rising seq. After an hour without the box, no readings at all. +func (x *Handle) Now() (Snapshot, error) { + if x.kind != Token { + return Snapshot{}, ErrDenied + } + x.h.mu.Lock() + defer x.h.mu.Unlock() + return x.viewLocked(NowClasses), nil +} + func (x *Handle) mayRead(class string) bool { scope, forwarded := cloudwire.ClassScope[class] return forwarded && (scope == "" || slices.Contains(x.scopes, scope)) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 162077a..95b5bcb 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -3,6 +3,7 @@ package store import ( "bytes" "errors" + "slices" "testing" "time" @@ -260,3 +261,87 @@ func TestABoxHandleCannotOpenASession(t *testing.T) { t.Fatal("the box's handle opened a session") } } + +// A token reaches its home through the roster entry it is bound to, at that +// epoch, and reads only Now: the newest live, prices and hb, and the newest +// live that carried sources. +func TestATokenReadsOnlyNow(t *testing.T) { + s, box, _ := filled(t) + _ = box.Put(cloudwire.MsgPrices, []byte("prices"), 8, false) + _ = box.Put(cloudwire.MsgLP, []byte("lp"), 9, false) + _ = box.Put(cloudwire.MsgHealth, []byte("health"), 10, false) + _ = box.Put(cloudwire.MsgTiles, []byte("tiles"), 11, false) + box.SetRoster(roster(2, protocol.CloudScopes...)) + x, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: "owner"}) + if err != nil { + t.Fatal(err) + } + snap, err := x.Now() + if err != nil { + t.Fatal(err) + } + var got []string + for _, e := range snap.Envelopes { + got = append(got, string(e)) + } + if want := []string{"live-full", "live-delta", "hb", "prices"}; !slices.Equal(got, want) { + t.Fatalf("a token read %v, want %v", got, want) + } + if _, err := x.Snapshot(); !errors.Is(err, ErrDenied) { + t.Fatal("a token read the whole snapshot") + } + if _, _, err := x.Open(); !errors.Is(err, ErrDenied) { + t.Fatal("a token opened a session") + } + dev, _ := s.For(Principal{Kind: Device, Home: "h1", DeviceKey: ownerKey}) + if _, err := dev.Now(); !errors.Is(err, ErrDenied) { + t.Fatal("a device key read Now as a token") + } +} + +// A token is held to the same rule as a device: a roster in force that lists +// its entry at its epoch, and no suspension. +func TestATokenIsAdmittedAsItsDeviceIs(t *testing.T) { + s, box, c := filled(t) + for name, p := range map[string]Principal{ + "no device": {Kind: Token, Home: "h1"}, + "another device": {Kind: Token, Home: "h1", DeviceID: "stranger"}, + "another epoch": {Kind: Token, Home: "h1", DeviceID: "owner", Epoch: 1}, + "another home": {Kind: Token, Home: "h2", DeviceID: "owner"}, + } { + if _, err := s.For(p); !errors.Is(err, ErrDenied) { + t.Errorf("%s: %v", name, err) + } + } + s.UseSuspensions(func(home, id string, epoch uint64) bool { return id == "viewer" }) + if _, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: "viewer"}); !errors.Is(err, ErrDenied) { + t.Fatal("a suspended device's token was admitted") + } + c.t = c.t.Add(24*time.Hour + time.Second) + if _, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: "owner"}); !errors.Is(err, ErrDenied) { + t.Fatal("a token read past an expired roster") + } + box.SetRoster(roster(2, protocol.ScopeLiveRead)) + if _, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: "owner"}); err != nil { + t.Fatal("a renewed roster still refused the token") + } + box.Delete() + if _, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: "owner"}); !errors.Is(err, ErrDenied) { + t.Fatal("a token reached a deleted home") + } +} + +// A token whose device may not read live gets the hb alone. +func TestATokenReadsWithinItsDevicesScope(t *testing.T) { + s, box, _ := filled(t) + _ = box.Put(cloudwire.MsgPrices, []byte("prices"), 8, false) + box.SetRoster(roster(2, protocol.ScopePlanRead)) + x, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: "viewer"}) + if err != nil { + t.Fatal(err) + } + snap, _ := x.Now() + if len(snap.Envelopes) != 1 || string(snap.Envelopes[0]) != "hb" { + t.Fatalf("a token without live read %q", snap.Envelopes) + } +} diff --git a/internal/token/token.go b/internal/token/token.go new file mode 100644 index 0000000..c499c8b --- /dev/null +++ b/internal/token/token.go @@ -0,0 +1,160 @@ +// Package token gives each roster device one extension token and serves +// GET /v1/now with it. docs/protocol-cloud.md in srcfl/ftw, "Extension +// token", is the wire format. +// +// A token is 32 random bytes, base64url without padding. The registry keeps +// its SHA-256 and the roster entry it is bound to, never the token. A read +// reaches the home only through store.For with that roster entry, so a token +// stops working the moment the roster drops the device, moves its epoch or +// runs out, an owner suspends the device, or the grant ends. It reads the +// newest live, prices and hb, as the box signed them, and nothing else. It +// is not a device key and cannot command. +package token + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/fxamacker/cbor/v2" + "github.com/srcfl/ftw-cloud/internal/registry" + "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +const ( + // Life is how long a token reads. The app asks for a new one at each + // foreground and background refresh. + Life = 7 * 24 * time.Hour + // tokenBytes is the token's length before base64url. + tokenBytes = 32 +) + +// Now is the body of GET /v1/now: the box's envelopes as it signed them, in +// rising seq, and when the service last heard from the box. lastSeenMs is +// the service's own word and is never shown as a reading. +type Now struct { + Envelopes []cbor.RawMessage `cbor:"envelopes"` + LastSeenMs int64 `cbor:"lastSeenMs"` +} + +// Service issues tokens and answers /v1/now. +type Service struct { + store *store.Store + reg *registry.Registry + log *slog.Logger + now func() time.Time +} + +// New builds the service. +func New(st *store.Store, reg *registry.Registry, log *slog.Logger) *Service { + if log == nil { + log = slog.Default() + } + return &Service{store: st, reg: reg, log: log, now: time.Now} +} + +// Issue makes a new token for a roster entry, in place of the device's old +// one. dev comes from store.For, never from the device's message. +func (s *Service) Issue(home string, dev cloudwire.Device) (cloudwire.Token, error) { + raw := make([]byte, tokenBytes) + if _, err := rand.Read(raw); err != nil { + return cloudwire.Token{}, err + } + hash := sha256.Sum256(raw) + row := registry.TokenRow{Home: home, DeviceID: dev.ID, Epoch: dev.Epoch, Expires: s.now().Add(Life)} + if err := s.reg.TokenIssue(row, hash[:]); err != nil { + return cloudwire.Token{}, err + } + return cloudwire.Token{Token: base64.RawURLEncoding.EncodeToString(raw), ExpiresInS: int64(Life / time.Second)}, nil +} + +// Drop forgets a device's token, as when an owner suspends it. +func (s *Service) Drop(home, deviceID string) error { + return s.reg.TokenDrop(home, deviceID) +} + +// Roster drops the tokens of devices the box's roster no longer lists at +// their epoch. It runs on the box's goroutine and never blocks for long. +func (s *Service) Roster(home string, r *cloudwire.Roster) { + if r == nil { + return + } + epochs := make(map[string]uint64, len(r.Devices)) + for _, d := range r.Devices { + epochs[d.ID] = d.Epoch + } + if err := s.reg.TokenPrune(home, func(id string, epoch uint64) bool { + e, ok := epochs[id] + return ok && e == epoch + }); err != nil { + s.log.Warn("could not drop the tokens a roster dropped", "err", err) + } +} + +// ServeHTTP is GET /v1/now. Every refusal is 401 with an empty body and says +// nothing about which check failed. +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.TLS == nil || r.TLS.Version != tls.VersionTLS13 { + http.Error(w, "TLS 1.3 only", http.StatusUpgradeRequired) + return + } + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + body, err := s.read(r.Header.Get("Authorization")) + switch { + case errors.Is(err, store.ErrDenied): + w.Header().Set("WWW-Authenticate", "Bearer") + w.WriteHeader(http.StatusUnauthorized) + return + case err != nil: + s.log.Warn("a token read failed", "err", err) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/cbor") + _, _ = w.Write(body) +} + +// read checks a bearer token and returns the body it may read. +func (s *Service) read(auth string) ([]byte, error) { + scheme, value, ok := strings.Cut(auth, " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + return nil, store.ErrDenied + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil || len(raw) != tokenBytes { + return nil, store.ErrDenied + } + hash := sha256.Sum256(raw) + row, ok, err := s.reg.TokenLookup(hash[:], s.now()) + if err != nil { + return nil, err + } + if !ok { + return nil, store.ErrDenied + } + x, err := s.store.For(store.Principal{Kind: store.Token, Home: row.Home, DeviceID: row.DeviceID, Epoch: row.Epoch}) + if err != nil { + return nil, store.ErrDenied + } + snap, err := x.Now() + if err != nil { + return nil, store.ErrDenied + } + out := Now{Envelopes: make([]cbor.RawMessage, len(snap.Envelopes)), LastSeenMs: snap.LastSeenMs} + for i, e := range snap.Envelopes { + out.Envelopes[i] = e + } + return cloudwire.Marshal(out) +} diff --git a/internal/token/token_test.go b/internal/token/token_test.go new file mode 100644 index 0000000..e5c61a8 --- /dev/null +++ b/internal/token/token_test.go @@ -0,0 +1,339 @@ +package token + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/registry" + "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +// A home with a signed copy of every class, an owner and a viewer in its +// roster, and /v1/now behind TLS 1.3. +type harness struct { + t *testing.T + clock *clock + store *store.Store + reg *registry.Registry + svc *Service + srv *httptest.Server + boxKey *ecdsa.PrivateKey + home string + box *store.Handle + seq uint64 + held map[string][]byte + rsq uint64 +} + +func newHarness(t *testing.T) *harness { + t.Helper() + c := &clock{t: time.Now()} + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reg.Close() }) + st := store.New(c.now) + st.UseSuspensions(reg.Suspended) + svc := New(st, reg, slog.New(slog.NewTextHandler(io.Discard, nil))) + svc.now = c.now + srv := httptest.NewUnstartedServer(svc) + srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} + srv.StartTLS() + t.Cleanup(srv.Close) + + boxKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + pk, _ := cloudwire.PublicKeyBytesOf(&boxKey.PublicKey) + home := cloudwire.HomeID(pk) + if err := reg.Claim(home, pk, c.t); err != nil { + t.Fatal(err) + } + box, _ := st.For(store.Principal{Kind: store.Box, Home: home}) + box.Connected() + h := &harness{t: t, clock: c, store: st, reg: reg, svc: svc, srv: srv, boxKey: boxKey, home: home, box: box, + held: map[string][]byte{}} + h.roster(1, protocol.CloudScopes...) + return h +} + +func (h *harness) devices(ownerEpoch uint64) []cloudwire.Device { + return []cloudwire.Device{ + {ID: "owner", Key: bytes.Repeat([]byte{1}, 32), Role: "owner", Scopes: protocol.CloudScopes, Epoch: ownerEpoch}, + {ID: "viewer", Key: bytes.Repeat([]byte{2}, 32), Role: "viewer", Scopes: []string{protocol.ScopeLiveRead}}, + } +} + +func (h *harness) roster(ownerEpoch uint64, grant ...string) *cloudwire.Roster { + return h.setRoster(h.devices(ownerEpoch), grant...) +} + +// setRoster holds a roster the way the uplink does, and lets the service drop +// the tokens it no longer allows. +func (h *harness) setRoster(devices []cloudwire.Device, grant ...string) *cloudwire.Roster { + h.rsq++ + r := &cloudwire.Roster{RosterSeq: h.rsq, TTLS: 86_400, Devices: devices, + Grant: cloudwire.Grant{ID: make([]byte, 16), Scopes: grant, Purposes: []string{protocol.CloudPurposeServe}, Controller: "sourceful"}} + if !h.box.SetRoster(r) { + h.t.Fatal("roster refused") + } + h.svc.Roster(h.home, r) + return r +} + +// put signs a body of class t as the box and holds it; name keys it for the +// test. +func (h *harness) put(name, t string, body any, table bool) { + h.t.Helper() + h.seq++ + hdr := cloudwire.Header{Seq: h.seq, Boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes), UptimeMs: int64(h.seq * 100)} + env, err := cloudwire.Seal(h.boxKey, h.home, t, struct { + cloudwire.Header + Body any `cbor:"x"` + }{hdr, body}) + if err != nil { + h.t.Fatal(err) + } + raw, _ := env.Encode() + if err := h.box.Put(t, raw, h.seq, table); err != nil { + h.t.Fatal(err) + } + h.held[name] = raw +} + +// fill holds one envelope of every class, in this order. +func (h *harness) fill() { + h.put("live full", cloudwire.MsgLive, "full", true) + h.put("prices", cloudwire.MsgPrices, "p", false) + h.put("lp", cloudwire.MsgLP, "lp", false) + h.put("plan", cloudwire.MsgPlan, "plan", false) + h.put("event", cloudwire.MsgEvent, "e", false) + h.put("tiles", cloudwire.MsgTiles, "t", false) + h.put("health", cloudwire.MsgHealth, "h", false) + h.put("live delta", cloudwire.MsgLive, "delta", false) + h.put("hb", cloudwire.MsgHB, "hb", false) +} + +func (h *harness) issue(id string, epoch uint64) string { + h.t.Helper() + tok, err := h.svc.Issue(h.home, cloudwire.Device{ID: id, Epoch: epoch}) + if err != nil { + h.t.Fatal(err) + } + if tok.ExpiresInS != 7*24*3600 || len(tok.Token) != 43 { + h.t.Fatalf("token %q expires in %d s", tok.Token, tok.ExpiresInS) + } + return tok.Token +} + +// get reads /v1/now with an Authorization header as given. +func (h *harness) get(auth string) (int, []byte) { + h.t.Helper() + req, _ := http.NewRequest(http.MethodGet, h.srv.URL+"/v1/now", nil) + if auth != "" { + req.Header.Set("Authorization", auth) + } + resp, err := h.srv.Client().Do(req) + if err != nil { + h.t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.Header.Get("Cache-Control") != "no-store" { + h.t.Fatal("a token read may be cached") + } + return resp.StatusCode, body +} + +func (h *harness) now(token string) Now { + h.t.Helper() + code, body := h.get("Bearer " + token) + if code != http.StatusOK { + h.t.Fatalf("token read: %d", code) + } + var n Now + if err := cloudwire.Unmarshal(body, &n); err != nil { + h.t.Fatal(err) + } + return n +} + +// The protocol's promise: a token reads live, prices and hb as the box +// signed them, in rising seq, and never lp, plan, event, tiles or health. +func TestATokenReadsLivePricesAndHBAndNothingElse(t *testing.T) { + h := newHarness(t) + h.fill() + n := h.now(h.issue("owner", 1)) + want := []string{"live full", "prices", "live delta", "hb"} + if len(n.Envelopes) != len(want) { + t.Fatalf("a token read %d envelopes, want %v", len(n.Envelopes), want) + } + for i, name := range want { + if !bytes.Equal(n.Envelopes[i], h.held[name]) { + t.Fatalf("envelope %d is not the box's %s", i, name) + } + env, err := cloudwire.DecodeEnvelope(n.Envelopes[i]) + if err != nil { + t.Fatal(err) + } + if err := cloudwire.Verify(&h.boxKey.PublicKey, h.home, env); err != nil { + t.Fatalf("%s does not verify with the box key: %v", name, err) + } + } + if n.LastSeenMs != h.clock.t.UnixMilli() { + t.Fatalf("lastSeenMs %d", n.LastSeenMs) + } +} + +// Every refusal is the same 401 with an empty body. +func TestRefusalsSayNothing(t *testing.T) { + h := newHarness(t) + h.fill() + good := h.issue("owner", 1) + stranger := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + for name, auth := range map[string]string{ + "no header": "", + "another scheme": "Basic " + good, + "not base64url": "Bearer !!!", + "too short": "Bearer " + good[:20], + "a token unknown": "Bearer " + stranger, + } { + code, body := h.get(auth) + if code != http.StatusUnauthorized || len(body) != 0 { + t.Errorf("%s: %d %q", name, code, body) + } + } + if code, _ := h.get("bearer " + good); code != http.StatusOK { + t.Fatalf("the scheme is case-insensitive: %d", code) + } +} + +// A token stops reading when its roster entry goes, moves its epoch, is +// suspended, when the roster runs out, when the token expires, when a new +// token replaces it, and when the home is purged. +func TestATokenDiesWithItsAccess(t *testing.T) { + refused := func(t *testing.T, h *harness, tok string) { + t.Helper() + if code, body := h.get("Bearer " + tok); code != http.StatusUnauthorized || len(body) != 0 { + t.Fatalf("still reads: %d", code) + } + } + t.Run("roster drops the device", func(t *testing.T) { + h := newHarness(t) + tok := h.issue("viewer", 0) + h.setRoster(h.devices(1)[:1], protocol.CloudScopes...) + refused(t, h, tok) + if _, ok, _ := h.reg.TokenLookup(hashOf(t, tok), h.clock.t); ok { + t.Fatal("the roster left the token's row") + } + }) + t.Run("new epoch", func(t *testing.T) { + h := newHarness(t) + tok := h.issue("owner", 1) + h.roster(2, protocol.CloudScopes...) + refused(t, h, tok) + }) + t.Run("suspended", func(t *testing.T) { + h := newHarness(t) + tok := h.issue("owner", 1) + _ = h.reg.Suspend(h.home, "owner", 1, h.clock.t) + refused(t, h, tok) + }) + t.Run("roster runs out", func(t *testing.T) { + h := newHarness(t) + tok := h.issue("owner", 1) + h.clock.t = h.clock.t.Add(24*time.Hour + time.Second) + refused(t, h, tok) + }) + t.Run("expires", func(t *testing.T) { + h := newHarness(t) + tok := h.issue("owner", 1) + h.clock.t = h.clock.t.Add(Life - time.Second) + h.roster(1, protocol.CloudScopes...) + if code, _ := h.get("Bearer " + tok); code != http.StatusOK { + t.Fatalf("a token a second from expiry: %d", code) + } + h.clock.t = h.clock.t.Add(time.Second) + refused(t, h, tok) + }) + t.Run("replaced", func(t *testing.T) { + h := newHarness(t) + old := h.issue("owner", 1) + fresh := h.issue("owner", 1) + refused(t, h, old) + if code, _ := h.get("Bearer " + fresh); code != http.StatusOK { + t.Fatalf("the new token: %d", code) + } + }) + t.Run("purged", func(t *testing.T) { + h := newHarness(t) + tok := h.issue("owner", 1) + _ = h.reg.Purge(h.home, nil, h.clock.t) + h.box.Delete() + refused(t, h, tok) + }) +} + +// Readings go an hour after the box does; the token then reads only when +// the box was last seen. +func TestATokenReadsNoReadingsAnHourAfterTheBox(t *testing.T) { + h := newHarness(t) + h.fill() + tok := h.issue("owner", 1) + gen := h.box.Connected() + h.box.Disconnected(gen) + h.clock.t = h.clock.t.Add(store.ReadingsKeptFor + time.Second) + n := h.now(tok) + if len(n.Envelopes) != 0 || n.LastSeenMs == 0 { + t.Fatalf("after an hour: %d envelopes, last seen %d", len(n.Envelopes), n.LastSeenMs) + } +} + +func TestOnlyGETOverTLS13(t *testing.T) { + h := newHarness(t) + tok := h.issue("owner", 1) + req, _ := http.NewRequest(http.MethodPost, h.srv.URL+"/v1/now", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := h.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST: %d", resp.StatusCode) + } + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v1/now", nil) + r.Header.Set("Authorization", "Bearer "+tok) + h.svc.ServeHTTP(w, r) + if w.Code != http.StatusUpgradeRequired || bytes.Contains(w.Body.Bytes(), []byte("envelopes")) { + t.Fatalf("plain HTTP: %d", w.Code) + } +} + +func hashOf(t *testing.T, tok string) []byte { + t.Helper() + raw, err := base64.RawURLEncoding.DecodeString(tok) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(raw) + return sum[:] +} From 4f35030fe1cc82164fc0dd5a0d5c4141b00c2fdd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:29:44 +0200 Subject: [PATCH 2/7] feat: reload widgets through Apple when prices or a car change A widget registers its push token with push.register {kind: widgets}, which needs ftw.live.read. When a box's priced hours reach further than before, as when tomorrow's prices arrive, or a car is plugged in, the service sends Apple's widgets push: no data, only content-changed, to the app's .push-type.widgets topic. The widgets then read /v1/now with their token. At most 4 an hour per home, since Apple budgets reloads. The uplink's OnEvent hook becomes OnHeld, which hands push every held envelope with its class. A roster prunes each kind by the scope it needs. The APNs client takes the push type and the app's bundle id. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- cmd/ftwcloudd/main.go | 7 +- deploy/apns.md | 10 +- internal/apns/apns.go | 51 ++++++-- internal/apns/apns_test.go | 34 +++++ internal/push/push.go | 180 ++++++++++++++++++++------ internal/push/widgets_test.go | 228 +++++++++++++++++++++++++++++++++ internal/registry/push.go | 16 ++- internal/registry/push_test.go | 2 +- internal/uplink/hooks_test.go | 29 ++++- internal/uplink/uplink.go | 16 +-- 11 files changed, 508 insertions(+), 73 deletions(-) create mode 100644 internal/push/widgets_test.go diff --git a/README.md b/README.md index 9da9a92..ca5f15f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ to it yet. device, and go when the roster drops them or Apple says they are gone. Apple's key comes from Parameter Store ([deploy/apns.md](deploy/apns.md)). +- **Widget push.** A widget registers its push token with `push.register + {kind: widgets}`, which needs `ftw.live.read`. When a box's prices reach + further than before, as when tomorrow's arrive, or a car is plugged in, + the service sends Apple's widgets push: no data, only "content changed". + The widgets then read `/v1/now`. At most 4 an hour per home, since Apple + budgets widget reloads. - **Widget token.** A device asks for its extension token over its app session with `token.get`. A widget reads `GET /v1/now` with it: the newest `live`, `prices` and `hb` as the box signed them, and nothing else. The @@ -61,7 +67,7 @@ Not built yet: | Step | What | Where | |---|---|---| -| C6 | Tiles, widget push | `internal/server`, `internal/push` | +| C6 | Tiles | `internal/server` | | C7–C8 | Statistics | not started | ## How it holds a home diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index edb78eb..be2e62c 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -102,8 +102,9 @@ func serve(args []string) error { srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log} // Alerts: the box's signed events to its phones through Apple, and - // box.unreachable when a box is away. Without a key the service keeps - // registrations and sends nothing. + // box.unreachable when a box is away. Widgets: a push with no data when + // prices or a car change. Without a key the service keeps registrations + // and sends nothing. var sender push.Sender switch client, err := loadAPNs(*apnsDir, *apnsTopic); { case err != nil: @@ -118,7 +119,7 @@ func serve(args []string) error { sender = client } pushSvc := push.New(reg, sender, log) - up.OnEvent = pushSvc.Event + up.OnHeld = pushSvc.Held up.OnRoster = func(home string, r *cloudwire.Roster) { pushSvc.Roster(home, r) tokens.Roster(home, r) diff --git a/deploy/apns.md b/deploy/apns.md index 2ec8968..5a0d347 100644 --- a/deploy/apns.md +++ b/deploy/apns.md @@ -1,7 +1,7 @@ # Apple's push key -`ftwcloudd` sends alerts through APNs with a token key (`.p8`) from Sourceful's -Apple team. Only the owner handles the key: he makes it at Apple and puts it +`ftwcloudd` sends alerts and widget pushes through APNs with a token key +(`.p8`) from Sourceful's Apple team. Only the owner handles the key: he makes it at Apple and puts it in AWS Systems Manager Parameter Store himself. The host reads it from there into tmpfs at every boot. It is never in the repository, the image, the volume or a log. @@ -12,13 +12,15 @@ or a log. Certificates, Identifiers & Profiles → **Keys** → **+**. 2. Name it `FTW Cloud APNs`, tick **Apple Push Notifications service (APNs)**, and choose **Configure**: environment **Sandbox & Production**; key - restriction **Topic Specific** with `energy.ftw.app`, or **Team Scoped**. + restriction **Team Scoped**, or **Topic Specific** with `energy.ftw.app` + and `energy.ftw.app.push-type.widgets`, the topic widget pushes go to. Save, then **Continue** → **Register**. 3. **Download** `AuthKey_.p8`. Apple lets you download it once. Note the 10-character **Key ID**. The Team ID is `L66M368PZ6`. 4. Under **Identifiers** → `energy.ftw.app`, check that **Push Notifications** and **Time Sensitive Notifications** are on. Xcode turns them on the first - time it signs the app with the new entitlements. + time it signs the app with the new entitlements. The widget extension, + `energy.ftw.app.widgets`, needs **Push Notifications** too. ## 2. Put it in Parameter Store (owner, once) diff --git a/internal/apns/apns.go b/internal/apns/apns.go index d16b37a..1870054 100644 --- a/internal/apns/apns.go +++ b/internal/apns/apns.go @@ -3,8 +3,8 @@ // by the team's .p8 key) that is renewed every 40 minutes. // // It knows nothing about homes or events. The caller hands it a device -// token and the payload bytes, and gets back what Apple said, including -// whether the token is gone for good. +// token, the app, the push type and the payload bytes, and gets back what +// Apple said, including whether the token is gone for good. package apns import ( @@ -41,6 +41,16 @@ const ( Production = "production" ) +// Push types. An alert shows on the phone; a widgets push carries no data +// and tells WidgetKit to reload the app's widgets, which Apple budgets. +const ( + PushAlert = "alert" + PushWidgets = "widgets" +) + +// widgetsTopicSuffix follows the app's bundle id in a widgets push's topic. +const widgetsTopicSuffix = ".push-type.widgets" + // TokenLife is how long one provider token is used. Apple refuses a token // older than an hour and one renewed more often than every 20 minutes. const TokenLife = 40 * time.Minute @@ -55,7 +65,8 @@ type Config struct { // KeyID is the key's 10-character id; TeamID the team's. KeyID string TeamID string - // Topic is the app's bundle id. + // Topic is the iPhone app's bundle id, for a notification that names + // no app. Topic string // Seams for tests. Production leaves them zero. @@ -130,7 +141,12 @@ type Notification struct { Environment string // DeviceToken is the token the phone got from iOS. DeviceToken []byte - Payload []byte + // App is the bundle id of the app the token belongs to; empty is the + // iPhone app, Config.Topic. + App string + // Type is PushAlert, the default when empty, or PushWidgets. + Type string + Payload []byte // Expiration is when Apple may stop trying to deliver. Zero means // deliver now or not at all. Expiration time.Time @@ -151,9 +167,18 @@ type Result struct { // OK reports whether Apple took the push. func (r Result) OK() bool { return r.Status == http.StatusOK } -// Send posts one alert. Every alert has the same priority and push type, -// and no collapse id, so nothing in the request says what kind it is. +// Send posts one push. Every alert has the same priority and push type, and +// no collapse id, so nothing in the request says what kind of event it +// carries. A widgets push goes to the app's widgets topic with Apple's +// default priority. func (c *Client) Send(ctx context.Context, n Notification) (Result, error) { + switch n.Type { + case "": + n.Type = PushAlert + case PushAlert, PushWidgets: + default: + return Result{}, fmt.Errorf("apns: unknown push type %q", n.Type) + } base := c.cfg.ProductionURL switch n.Environment { case Production: @@ -185,10 +210,18 @@ func (c *Client) post(ctx context.Context, base string, n Notification, renew bo if err != nil { return Result{}, err } + topic := n.App + if topic == "" { + topic = c.cfg.Topic + } req.Header.Set("authorization", "bearer "+token) - req.Header.Set("apns-topic", c.cfg.Topic) - req.Header.Set("apns-push-type", "alert") - req.Header.Set("apns-priority", "10") + req.Header.Set("apns-push-type", n.Type) + if n.Type == PushWidgets { + req.Header.Set("apns-topic", topic+widgetsTopicSuffix) + } else { + req.Header.Set("apns-topic", topic) + req.Header.Set("apns-priority", "10") + } exp := int64(0) if !n.Expiration.IsZero() { exp = n.Expiration.Unix() diff --git a/internal/apns/apns_test.go b/internal/apns/apns_test.go index 42fe07a..8f94a61 100644 --- a/internal/apns/apns_test.go +++ b/internal/apns/apns_test.go @@ -234,3 +234,37 @@ func TestOnlyAnAppleKeyIsTaken(t *testing.T) { t.Fatal("sent to an environment that does not exist") } } + +// A widgets push goes to the app's widgets topic with Apple's own push type +// and default priority; a token from another app names that app's topic. +func TestAWidgetsPushUsesTheWidgetsTopic(t *testing.T) { + p8, key := testP8(t) + apple := newFakeApple(t, &key.PublicKey) + now := time.Unix(1_791_820_800, 0) + c := apple.client(t, p8, func() time.Time { return now }) + payload := []byte(`{"aps":{"content-changed":true}}`) + for _, n := range []Notification{ + {Environment: Production, DeviceToken: []byte{1}, Type: PushWidgets, Payload: payload}, + {Environment: Production, DeviceToken: []byte{2}, Type: PushWidgets, App: "energy.ftw.app.watchkitapp", Payload: payload}, + {Environment: Production, DeviceToken: []byte{3}, App: "energy.ftw.app.watchkitapp", Payload: payload}, + } { + if res, err := c.Send(context.Background(), n); err != nil || !res.OK() { + t.Fatalf("send: %+v %v", res, err) + } + } + for i, want := range []struct{ topic, kind, priority string }{ + {"energy.ftw.app.push-type.widgets", "widgets", ""}, + {"energy.ftw.app.watchkitapp.push-type.widgets", "widgets", ""}, + {"energy.ftw.app.watchkitapp", "alert", "10"}, + } { + r := apple.reqs[i] + if r.Header.Get("apns-topic") != want.topic || r.Header.Get("apns-push-type") != want.kind || + r.Header.Get("apns-priority") != want.priority { + t.Errorf("push %d: topic %q, type %q, priority %q", i, r.Header.Get("apns-topic"), + r.Header.Get("apns-push-type"), r.Header.Get("apns-priority")) + } + } + if _, err := c.Send(context.Background(), Notification{Environment: Production, DeviceToken: []byte{4}, Type: "background"}); err == nil { + t.Fatal("an unknown push type was sent") + } +} diff --git a/internal/push/push.go b/internal/push/push.go index 772f3ed..57a50aa 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -11,11 +11,17 @@ // saying it was restarting on purpose. It carries no arguments and no // buttons. // -// Tokens are bound to roster entries. Only a device whose role and the -// owner's grant both allow ftw.events.read may register, and a roster that -// drops the device, moves its epoch or narrows its scope drops its tokens. -// A device an owner has suspended gets no alerts until the suspension ends. -// Nothing here logs a token or a home id. +// A widgets push carries no data. It tells WidgetKit to reload the app's +// widgets, which then read GET /v1/now with their own token. It goes when +// a box's priced hours reach further than before, as when tomorrow's prices +// arrive, and when a car is plugged in. +// +// Tokens are bound to roster entries. A device registers for alerts only if +// its role and the owner's grant both allow ftw.events.read, and for widgets +// only if they allow ftw.live.read. A roster that drops the device, moves +// its epoch or narrows its scope drops its tokens. A device an owner has +// suspended gets nothing until the suspension ends. Nothing here logs a +// token or a home id. package push import ( @@ -37,26 +43,52 @@ const ( // raises a few notifications a day; past this, something is wrong and // the rest are dropped. MaxAlertsPerHour = 20 + // MaxWidgetPushesPerHour bounds a home's widget pushes. Prices arrive + // once a day and a car is plugged in a few times; Apple budgets widget + // reloads, so a car that flaps between plugged and not must not spend + // the budget. + MaxWidgetPushesPerHour = 4 // eventExpiry is how long Apple may keep trying to deliver an event. // Later than that the phone would show only the generic line. eventExpiry = cloudwire.AlertMaxAgeMs * time.Millisecond // unreachableExpiry is the same for box.unreachable, which stays true - // while the box is away. + // while the box is away, and for a widgets push, which stays true until + // the next change. unreachableExpiry = time.Hour + widgetsExpiry = time.Hour // queueSize bounds the alerts waiting to be sent. queueSize = 256 // expireEvery is how often registrations of long-silent homes go. expireEvery = time.Hour ) +// KindWidgets registers a widget's push token. cloudwire names only +// PushKindAlert so far; the protocol's Push section names widgets. +const KindWidgets = "widgets" + +// widgetsPayload is the whole widgets push, as Apple defines it. +var widgetsPayload = []byte(`{"aps":{"content-changed":true}}`) + +// kind is what one kind of registration needs and how it goes to Apple. +type kind struct { + scope string + pushType string + perHour int +} + +var kinds = map[string]kind{ + cloudwire.PushKindAlert: {protocol.ScopeEventsRead, apns.PushAlert, MaxAlertsPerHour}, + KindWidgets: {protocol.ScopeLiveRead, apns.PushWidgets, MaxWidgetPushesPerHour}, +} + // Sender is Apple. apns.Client is one. type Sender interface { Send(ctx context.Context, n apns.Notification) (apns.Result, error) } // ErrNotForThisDevice refuses a registration the device may not make: a -// viewer, a grant without events, or a kind that does not exist yet. The -// app session goes on. +// device without the scope its kind needs, or a kind that does not exist +// yet. The app session goes on. var ErrNotForThisDevice = errors.New("push: not a registration this device may make") // Service keeps registrations and sends alerts. @@ -76,7 +108,7 @@ type Service struct { } // homeState is one home's box connection, as the dead man's switch sees it, -// and its recent alerts. +// its recent pushes per kind, and what its widgets show that can change. type homeState struct { online bool // gen moves on every connect and disconnect, so a timer set for one @@ -84,7 +116,12 @@ type homeState struct { gen uint64 stop func() bool told bool - sent []time.Time + sent map[string][]time.Time + // pricedUntil is where the newest prices end, 0 before the first. + pricedUntil int64 + // plugged is each loadpoint's plug in the newest lp, nil before the + // first. + plugged map[string]bool } // New builds the service. A nil sender keeps registrations and sends @@ -139,7 +176,8 @@ func (s *Service) enqueue(job func(context.Context)) { // Register keeps a device's APNs token. dev is the roster entry the phone's // Noise key matched and scopes its effective scope, both from store.For. func (s *Service) Register(home string, dev cloudwire.Device, scopes []string, r cloudwire.PushRegister) error { - if r.Kind != cloudwire.PushKindAlert || !slices.Contains(scopes, protocol.ScopeEventsRead) || + k, known := kinds[r.Kind] + if !known || !slices.Contains(scopes, k.scope) || len(r.APNsToken) == 0 || len(r.APNsToken) > cloudwire.APNsTokenMaxBytes || (r.Environment != cloudwire.PushSandbox && r.Environment != cloudwire.PushProduction) { return ErrNotForThisDevice @@ -158,25 +196,39 @@ func (s *Service) Unregister(home string, dev cloudwire.Device, r cloudwire.Push // -------------------------------------------------------------------------- // Roster drops the tokens of devices the box's roster no longer lets read -// events: gone, paired again under a new epoch, or narrowed. +// what their kind carries: gone, paired again under a new epoch, or +// narrowed. func (s *Service) Roster(home string, r *cloudwire.Roster) { if r == nil { return } - allowed := map[string]uint64{} + devices := make(map[string]cloudwire.Device, len(r.Devices)) for _, d := range r.Devices { - if slices.Contains(d.Scopes, protocol.ScopeEventsRead) && slices.Contains(r.Grant.Scopes, protocol.ScopeEventsRead) { - allowed[d.ID] = d.Epoch - } + devices[d.ID] = d } - if err := s.reg.PushPrune(home, func(id string, epoch uint64) bool { - e, ok := allowed[id] - return ok && e == epoch + if err := s.reg.PushPrune(home, func(id string, epoch uint64, kindName string) bool { + d, ok := devices[id] + k, known := kinds[kindName] + return ok && known && d.Epoch == epoch && slices.Contains(d.Scopes, k.scope) && slices.Contains(r.Grant.Scopes, k.scope) }); err != nil { s.log.Warn("could not drop the tokens a roster dropped", "err", err) } } +// Held gets every envelope the service holds, after the uplink checked the +// box's signature: an event goes out as an alert, and new prices or a car +// plugged in send a widgets push. +func (s *Service) Held(home, class string, envelope []byte) { + switch class { + case cloudwire.MsgEvent: + s.Event(home, envelope) + case cloudwire.MsgPrices, cloudwire.MsgLP: + if s.widgetsChanged(home, class, envelope) { + s.enqueue(func(ctx context.Context) { s.send(ctx, home, KindWidgets, widgetsPayload, widgetsExpiry) }) + } + } +} + // Event sends a box-signed event envelope, as it arrived, to the home's // registered phones. func (s *Service) Event(home string, envelope []byte) { @@ -184,7 +236,60 @@ func (s *Service) Event(home string, envelope []byte) { if err != nil { return } - s.enqueue(func(ctx context.Context) { s.send(ctx, home, payload, eventExpiry) }) + s.enqueue(func(ctx context.Context) { s.send(ctx, home, cloudwire.PushKindAlert, payload, eventExpiry) }) +} + +// widgetsChanged reports whether prices now reach further than before, or a +// loadpoint is plugged in that was not. The first of each after a restart +// only sets what the next is compared with. +func (s *Service) widgetsChanged(home, class string, envelope []byte) bool { + env, err := cloudwire.DecodeEnvelope(envelope) + if err != nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.state(home) + switch class { + case cloudwire.MsgPrices: + var p cloudwire.Prices + if cloudwire.Unmarshal(env.B, &p) != nil { + return false + } + until := pricedUntil(p) + changed := st.pricedUntil != 0 && until > st.pricedUntil + if until > st.pricedUntil { + st.pricedUntil = until + } + return changed + default: + var lp cloudwire.LP + if cloudwire.Unmarshal(env.B, &lp) != nil { + return false + } + plugged := make(map[string]bool, len(lp.Loadpoints)) + changed := false + for _, l := range lp.Loadpoints { + plugged[l.ID] = l.Plugged + changed = changed || (st.plugged != nil && l.Plugged && !st.plugged[l.ID]) + } + st.plugged = plugged + return changed + } +} + +// pricedUntil is where the last priced slot ends. +func pricedUntil(p cloudwire.Prices) int64 { + last := -1 + for i := range max(len(p.SpotMinor), len(p.TotalMinor)) { + if i < len(p.SpotMinor) && p.SpotMinor[i] != nil || i < len(p.TotalMinor) && p.TotalMinor[i] != nil { + last = i + } + } + if last < 0 || p.SlotMs <= 0 { + return 0 + } + return p.T0Ms + int64(last+1)*p.SlotMs } // BoxConnected stops the dead man's switch for a home whose box is back. @@ -243,45 +348,45 @@ func (s *Service) unreachable(home string, gen uint64) { if err != nil { return } - s.enqueue(func(ctx context.Context) { s.send(ctx, home, payload, unreachableExpiry) }) + s.enqueue(func(ctx context.Context) { s.send(ctx, home, cloudwire.PushKindAlert, payload, unreachableExpiry) }) } func (s *Service) state(home string) *homeState { st := s.homes[home] if st == nil { - st = &homeState{} + st = &homeState{sent: map[string][]time.Time{}} s.homes[home] = st } return st } -// allow counts one alert against the home's hourly cap. -func (s *Service) allow(home string) bool { +// allow counts one push of a kind against the home's hourly cap. +func (s *Service) allow(home, kindName string) bool { s.mu.Lock() defer s.mu.Unlock() st := s.state(home) now := s.now() - keep := st.sent[:0] - for _, at := range st.sent { + var keep []time.Time + for _, at := range st.sent[kindName] { if now.Sub(at) < time.Hour { keep = append(keep, at) } } - st.sent = keep - if len(st.sent) >= MaxAlertsPerHour { + if len(keep) >= kinds[kindName].perHour { + st.sent[kindName] = keep return false } - st.sent = append(st.sent, now) + st.sent[kindName] = append(keep, now) return true } -// send posts one payload to every phone of the home registered for alerts, +// send posts one payload to every device of the home registered for a kind, // and forgets the tokens Apple says are gone. -func (s *Service) send(ctx context.Context, home string, payload []byte, expiry time.Duration) { +func (s *Service) send(ctx context.Context, home, kindName string, payload []byte, expiry time.Duration) { if s.sender == nil { return } - regs, err := s.reg.PushRegs(home, cloudwire.PushKindAlert) + regs, err := s.reg.PushRegs(home, kindName) if err != nil { s.log.Warn("could not read push registrations", "err", err) return @@ -294,15 +399,16 @@ func (s *Service) send(ctx context.Context, home string, payload []byte, expiry if len(regs) == 0 { return } - if !s.allow(home) { - s.log.Warn("a home passed its alert cap; alert dropped", "cap_per_hour", MaxAlertsPerHour) + k := kinds[kindName] + if !s.allow(home, kindName) { + s.log.Warn("a home passed its push cap; push dropped", "kind", kindName, "cap_per_hour", k.perHour) return } var sent, gone, failed int exp := s.now().Add(expiry) for _, r := range regs { res, err := s.sender.Send(ctx, apns.Notification{Environment: r.Environment, DeviceToken: r.Token, - Payload: payload, Expiration: exp}) + Type: k.pushType, Payload: payload, Expiration: exp}) switch { case err != nil: failed++ @@ -315,8 +421,8 @@ func (s *Service) send(ctx context.Context, home string, payload []byte, expiry } default: failed++ - s.log.Warn("APNs refused an alert", "status", res.Status, "reason", res.Reason) + s.log.Warn("APNs refused a push", "kind", kindName, "status", res.Status, "reason", res.Reason) } } - s.log.Info("alert sent", "phones", sent, "gone", gone, "failed", failed) + s.log.Info("push sent", "kind", kindName, "devices", sent, "gone", gone, "failed", failed) } diff --git a/internal/push/widgets_test.go b/internal/push/widgets_test.go new file mode 100644 index 0000000..aa81003 --- /dev/null +++ b/internal/push/widgets_test.go @@ -0,0 +1,228 @@ +package push + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/apns" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +const ( + day = int64(24 * 3600 * 1000) + hour = int64(3600 * 1000) +) + +// boxSigner signs the prices and lp a box sends, so the service reads them +// as the uplink hands them over. +type boxSigner struct { + t *testing.T + key *ecdsa.PrivateKey + seq uint64 +} + +func newBoxSigner(t *testing.T) *boxSigner { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &boxSigner{t: t, key: key} +} + +func (b *boxSigner) envelope(home, class string, body any) []byte { + b.t.Helper() + env, err := cloudwire.Seal(b.key, home, class, body) + if err != nil { + b.t.Fatal(err) + } + raw, err := env.Encode() + if err != nil { + b.t.Fatal(err) + } + return raw +} + +func (b *boxSigner) header() cloudwire.Header { + b.seq++ + return cloudwire.Header{Seq: b.seq, Boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes)} +} + +// prices covers n hourly slots from t0, priced up to the slot priced. +func (b *boxSigner) prices(home string, t0 int64, n, priced int) []byte { + p := cloudwire.Prices{Header: b.header(), Zone: "SE3", Currency: "SEK", T0Ms: t0, SlotMs: hour, + SpotMinor: make([]*int64, n), TotalMinor: make([]*int64, n)} + for i := range priced { + v := int64(100 + i) + p.SpotMinor[i], p.TotalMinor[i] = &v, &v + } + return b.envelope(home, cloudwire.MsgPrices, p) +} + +func (b *boxSigner) lp(home string, plugged ...bool) []byte { + l := cloudwire.LP{Header: b.header(), Loadpoints: []cloudwire.Loadpoint{}, Feedback: []cloudwire.Feedback{}} + for i, p := range plugged { + l.Loadpoints = append(l.Loadpoints, cloudwire.Loadpoint{ID: string(rune('a' + i)), Src: "src", Plugged: p}) + } + return b.envelope(home, cloudwire.MsgLP, l) +} + +func (r *rig) registerWidgets(t *testing.T, dev cloudwire.Device, scopes []string, token string) error { + t.Helper() + return r.svc.Register(r.home, dev, scopes, cloudwire.PushRegister{ + APNsToken: []byte(token), Environment: cloudwire.PushSandbox, Kind: KindWidgets}) +} + +// taken returns what went to Apple since the last call. +func (r *rig) taken() []apns.Notification { + r.drain() + r.apple.mu.Lock() + defer r.apple.mu.Unlock() + out := r.apple.sent + r.apple.sent = nil + return out +} + +// A widget registers with ftw.live.read, as a viewer's may; alerts still need +// ftw.events.read. +func TestAWidgetRegistersWithLiveRead(t *testing.T) { + r := newRig(t) + if err := r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-widgets"); err != nil { + t.Fatalf("a viewer's widget: %v", err) + } + if err := r.registerWidgets(t, r.owner, []string{protocol.ScopeEventsRead}, "no-live"); err != ErrNotForThisDevice { + t.Fatalf("a device without live read registered a widget: %v", err) + } + regs, _ := r.reg.PushRegs(r.home, KindWidgets) + if len(regs) != 1 || regs[0].DeviceID != r.viewer.ID { + t.Fatalf("widgets %+v", regs) + } + if alerts, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(alerts) != 0 { + t.Fatal("a widget registration became an alert") + } +} + +// Prices that reach further than before, as when tomorrow's arrive, send one +// widgets push to the widgets and nothing to the alerts. The first prices +// after a start, a resend and the shift at midnight send nothing. +func TestTomorrowsPricesReloadTheWidgets(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.register(t, r.owner, r.all, "owner-alerts") + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + _ = r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-widgets") + today := int64(1_791_756_000_000) + + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today, 48, 24)) + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today, 48, 24)) + if got := r.taken(); len(got) != 0 { + t.Fatalf("today's prices sent %d", len(got)) + } + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today, 48, 48)) + got := r.taken() + if len(got) != 2 { + t.Fatalf("tomorrow's prices went to %d devices", len(got)) + } + for _, n := range got { + if n.Type != apns.PushWidgets || string(n.Payload) != `{"aps":{"content-changed":true}}` || + !bytes.HasSuffix(n.DeviceToken, []byte("-widgets")) { + t.Fatalf("push %+v", n) + } + } + // Midnight: the day moves on, and the priced hours end where they did. + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today+day, 24, 24)) + if got := r.taken(); len(got) != 0 { + t.Fatalf("midnight sent %d", len(got)) + } +} + +// A car plugged in sends one widgets push. Staying plugged, unplugging and +// the first lp after a start send nothing. +func TestACarPluggedInReloadsTheWidgets(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + for i, step := range []struct { + plugged []bool + pushes int + }{ + {[]bool{true, false}, 0}, // the first lp only sets what the next is compared with + {[]bool{true, false}, 0}, + {[]bool{true, true}, 1}, + {[]bool{false, true}, 0}, + {[]bool{true, true}, 1}, + } { + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, step.plugged...)) + if got := r.taken(); len(got) != step.pushes { + t.Fatalf("step %d sent %d", i, len(got)) + } + } +} + +// A car that flaps between plugged and not cannot spend Apple's budget for +// the home's widgets. +func TestWidgetPushesAreCappedPerHome(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + sent := 0 + for range MaxWidgetPushesPerHour + 3 { + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + sent += len(r.taken()) + } + if sent != MaxWidgetPushesPerHour { + t.Fatalf("sent %d in an hour", sent) + } + r.clock.advance(time.Hour) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + if got := r.taken(); len(got) != 1 { + t.Fatalf("the cap did not lift after an hour: %d", len(got)) + } + // The alert cap is its own. + _ = r.register(t, r.owner, r.all, "owner-alerts") + r.svc.Event(r.home, []byte{0xa0}) + if got := r.taken(); len(got) != 1 || got[0].Type != apns.PushAlert { + t.Fatalf("an alert after the widgets: %+v", got) + } +} + +// A roster prunes each kind by its own scope: a device that loses events +// keeps its widgets, and one that loses live loses them. +func TestARosterPrunesEachKindByItsScope(t *testing.T) { + r := newRig(t) + _ = r.register(t, r.owner, r.all, "alerts") + _ = r.registerWidgets(t, r.owner, r.all, "widgets") + grant := cloudwire.Grant{ID: bytes.Repeat([]byte{4}, 16), Scopes: r.all} + count := func(kind string) int { + regs, _ := r.reg.PushRegs(r.home, kind) + return len(regs) + } + noEvents := r.owner + noEvents.Scopes = []string{protocol.ScopeLiveRead} + r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{noEvents}, Grant: grant}) + if count(cloudwire.PushKindAlert) != 0 || count(KindWidgets) != 1 { + t.Fatal("losing events did not drop alerts alone") + } + grant.Scopes = []string{protocol.ScopeEventsRead} + r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{r.owner}, Grant: grant}) + if count(KindWidgets) != 0 { + t.Fatal("a grant without live kept the widgets") + } +} + +// A suspended device's widgets get nothing, as its alerts get nothing. +func TestASuspendedDevicesWidgetsGetNothing(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + _ = r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-widgets") + _ = r.reg.Suspend(r.home, r.viewer.ID, r.viewer.Epoch, r.clock.time()) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + if got := r.taken(); len(got) != 1 || string(got[0].DeviceToken) != "owner-widgets" { + t.Fatalf("sent to %+v", got) + } +} diff --git a/internal/registry/push.go b/internal/registry/push.go index 1bc25ec..decc546 100644 --- a/internal/registry/push.go +++ b/internal/registry/push.go @@ -91,31 +91,33 @@ func (r *Registry) PushGone(home string, token []byte, since time.Time) error { return err } -// PushPrune keeps only the registrations whose device keep allows at its -// epoch, as a new roster says. -func (r *Registry) PushPrune(home string, keep func(deviceID string, epoch uint64) bool) error { - rows, err := r.db.Query(`SELECT DISTINCT device_id, epoch FROM push WHERE home = ?`, home) +// PushPrune keeps only the registrations keep allows: a device at its epoch, +// for one kind, as a new roster says. +func (r *Registry) PushPrune(home string, keep func(deviceID string, epoch uint64, kind string) bool) error { + rows, err := r.db.Query(`SELECT DISTINCT device_id, epoch, kind FROM push WHERE home = ?`, home) if err != nil { return err } type entry struct { id string epoch int64 + kind string } var gone []entry for rows.Next() { var e entry - if err := rows.Scan(&e.id, &e.epoch); err != nil { + if err := rows.Scan(&e.id, &e.epoch, &e.kind); err != nil { _ = rows.Close() return err } - if !keep(e.id, uint64(e.epoch)) { + if !keep(e.id, uint64(e.epoch), e.kind) { gone = append(gone, e) } } _ = rows.Close() for _, e := range gone { - if _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND epoch = ?`, home, e.id, e.epoch); err != nil { + if _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND epoch = ? AND kind = ?`, + home, e.id, e.epoch, e.kind); err != nil { return err } } diff --git a/internal/registry/push_test.go b/internal/registry/push_test.go index d2931d7..ec6ea14 100644 --- a/internal/registry/push_test.go +++ b/internal/registry/push_test.go @@ -27,7 +27,7 @@ func TestARegistrationBelongsToARosterEntryAndGoesWithIt(t *testing.T) { } // The roster drops phone-b and moves phone-a's epoch: both go. - if err := r.PushPrune("h1", func(id string, epoch uint64) bool { return id == "phone-a" && epoch == 2 }); err != nil { + if err := r.PushPrune("h1", func(id string, epoch uint64, _ string) bool { return id == "phone-a" && epoch == 2 }); err != nil { t.Fatal(err) } if got, _ := r.PushRegs("h1", "alert"); len(got) != 0 { diff --git a/internal/uplink/hooks_test.go b/internal/uplink/hooks_test.go index 8ffe84d..12c07c1 100644 --- a/internal/uplink/hooks_test.go +++ b/internal/uplink/hooks_test.go @@ -14,6 +14,7 @@ import ( type heard struct { mu sync.Mutex events [][]byte + classes []string rosters []uint64 gone []string conns int @@ -22,10 +23,13 @@ type heard struct { func listen(s *service) *heard { h := &heard{} s.up.OnConnect = func(string) { h.mu.Lock(); h.conns++; h.mu.Unlock() } - s.up.OnEvent = func(home string, env []byte) { + s.up.OnHeld = func(home, class string, env []byte) { h.mu.Lock() - h.events = append(h.events, env) - h.mu.Unlock() + defer h.mu.Unlock() + h.classes = append(h.classes, class) + if class == cloudwire.MsgEvent { + h.events = append(h.events, env) + } } s.up.OnRoster = func(home string, r *cloudwire.Roster) { h.mu.Lock() @@ -68,6 +72,25 @@ func TestAnEventReachesPushUnchanged(t *testing.T) { } } +// Every envelope the service holds reaches push with its class, so prices +// and a car plugged in can reload widgets. A roster is not held this way. +func TestEveryHeldEnvelopeReachesPushWithItsClass(t *testing.T) { + s := newService(t) + h := listen(s) + b := newBox(t) + _, _ = b.connect(s, nil) + b.send(cloudwire.MsgRoster, b.roster(protocol.ScopeLiveRead)) + b.send(cloudwire.MsgPrices, cloudwire.Prices{Header: b.header(), SpotMinor: []*int64{}, TotalMinor: []*int64{}}) + b.send(cloudwire.MsgLP, cloudwire.LP{Header: b.header(), Loadpoints: []cloudwire.Loadpoint{}, Feedback: []cloudwire.Feedback{}}) + classes := h.count(func(h *heard) int { return len(h.classes) }) + wait(t, "two held envelopes", func() bool { return classes() == 2 }) + h.mu.Lock() + defer h.mu.Unlock() + if h.classes[0] != cloudwire.MsgPrices || h.classes[1] != cloudwire.MsgLP { + t.Fatalf("push heard %v", h.classes) + } +} + // The end of an uplink tells push why: the box's bye, or nothing when the // socket dropped. A connection a newer one replaced says nothing. func TestTheEndOfAnUplinkSaysWhy(t *testing.T) { diff --git a/internal/uplink/uplink.go b/internal/uplink/uplink.go index 870d7f7..49bb46f 100644 --- a/internal/uplink/uplink.go +++ b/internal/uplink/uplink.go @@ -67,12 +67,12 @@ type Uplink struct { // box that just came back for one-second updates and push can stop // waiting to say it is out of reach. Set before serving. OnConnect func(home string) - // OnEvent gets each event the box signed, as the envelope it arrived - // in; OnRoster each roster the service keeps; OnGone the end of the - // home's current uplink, with the reason the box gave in its bye, or - // "" when the socket just dropped. Push uses them. Each runs on the - // box's goroutine and must not block. Set before serving. - OnEvent func(home string, envelope []byte) + // OnHeld gets each envelope the service holds, with its class, as the + // box signed it; OnRoster each roster the service keeps; OnGone the end + // of the home's current uplink, with the reason the box gave in its + // bye, or "" when the socket just dropped. Push uses them. Each runs on + // the box's goroutine and must not block. Set before serving. + OnHeld func(home, class string, envelope []byte) OnRoster func(home string, r *cloudwire.Roster) OnGone func(home, why string) } @@ -369,8 +369,8 @@ func (c *boxConn) apply(env cloudwire.Envelope) (stop bool, err error) { if err := c.handle.Put(env.T, raw, h.Seq, env.T == cloudwire.MsgLive && carriesTable(env.B)); err != nil { return true, err } - if env.T == cloudwire.MsgEvent && c.u.OnEvent != nil { - c.u.OnEvent(c.home, raw) + if c.u.OnHeld != nil { + c.u.OnHeld(c.home, env.T, raw) } return false, nil } From 6cc84e63df778cbabf0e0859cd90bfab0f35e579 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:41:30 +0200 Subject: [PATCH 3/7] feat: a watch reads its home at /v1/app/once in one HTTPS request watchOS gives an app HTTPS and nothing lower, so a watch cannot open /v1/app. POST /v1/app/once?home= carries Noise IK message 1 with the prologue ftw.cloud.once.v1: and the home id, and one unsigned envelope: read {have, hz}, token.get, push.register or push.unregister. The service admits the device as /v1/app admits a phone, through store.For with the key the handshake proved, and answers with message 2 and, for a read, the envelopes that did not fit, each after its u16 length. It keeps no session and no Noise state after the answer. - read: the app session's snapshot less what have covers; hz 1 asks the box for one-second updates. - token.get: the watch's own token, as on the app session. - push.register takes app iphone or watch; a watch's pushes go to the watch app's topic, with the same alert payload as its phone's. - Limits: one read at a time per device and 30 a minute, 16 at once per home, 64 at once in the process, message 1 at most 4 KiB, the answer within 10 s. 400, 403, 429 and 501 have empty bodies. The service answers srcfl/ftw's cloud-once-vectors.json byte for byte, read from the pinned go/protocol. An end-to-end test runs a box on the uplink and a Go watch client over TLS, and a fuzz test covers message 1. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + CLAUDE.md | 6 +- README.md | 27 +- cmd/ftwcloudd/main.go | 4 +- internal/appsession/appsession.go | 19 +- internal/appsession/fuzz_test.go | 53 ++ internal/appsession/harness_test.go | 13 +- internal/appsession/once.go | 300 ++++++++++ internal/appsession/once_test.go | 695 +++++++++++++++++++++++ internal/appsession/once_vectors_test.go | 157 +++++ internal/appsession/perf_test.go | 2 +- internal/appsession/token_test.go | 22 +- internal/push/push.go | 29 +- internal/push/widgets_test.go | 71 +++ internal/registry/push.go | 42 +- internal/registry/push_test.go | 40 ++ internal/server/e2e_test.go | 546 ++++++++++++++++++ internal/server/server.go | 8 +- internal/server/server_test.go | 20 +- 19 files changed, 2005 insertions(+), 51 deletions(-) create mode 100644 internal/appsession/once.go create mode 100644 internal/appsession/once_test.go create mode 100644 internal/appsession/once_vectors_test.go create mode 100644 internal/server/e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 932108b..1c838f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,5 +25,7 @@ jobs: run: bash scripts/check-imports.sh - name: What a box can send never panics the service run: go test -run XXX -fuzz FuzzBoxMessage -fuzztime 30s ./internal/uplink + - name: What a watch can post never panics the service + run: go test -run XXX -fuzz FuzzOnceMessage -fuzztime 30s ./internal/appsession - name: Builds for the host run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd diff --git a/CLAUDE.md b/CLAUDE.md index ebc8527..04e4921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,8 +22,10 @@ maintains it. id, the box key, the signed roster, owners' suspensions, tombstones, APNs registrations and the hashes of extension tokens. Nothing logs addresses. - **A phone can only narrow.** On `/v1/app` it can say whether it is looking, - and an owner can suspend a roster device. The service admits a phone by the - key its Noise handshake proved, and only if the box's roster lists it. + and an owner can suspend a roster device. On `/v1/app/once` a watch can + read, ask for its token and register for push, and nothing else. The + service admits a phone or a watch by the key its Noise handshake proved, + and only if the box's roster lists it. - **TLS ends in `ftwcloudd`.** The handshake is bound to the TLS session; a terminating proxy breaks it on purpose. A TCP load balancer is fine. - **Forward the box's bytes unchanged.** The service cannot sign for a box, diff --git a/README.md b/README.md index ca5f15f..22b2a58 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ to it yet. closes the sessions it touches at once. - While a phone looks, the service asks the box for one-second updates and asks again every 50 s; when nobody looks, it stops. +- **One-shot read (ADR 0009).** watchOS gives an app HTTPS and nothing + lower, so a watch reads at `POST /v1/app/once?home=`: the body is + Noise IK message 1 with the prologue `ftw.cloud.once.v1:` and the home id, + and it carries one of `read {have, hz}`, `token.get`, `push.register` or + `push.unregister`. The service admits the watch exactly as `/v1/app` + admits a phone: its key in the box's roster, where the box lists it with + role `watch`, a roster that has not run out, no suspension. A refusal is + 403 with an empty body. The answer is message 2 with the snapshot, less + the envelopes `have` says the watch holds, and the envelopes that did not + fit after it, each after its length. The box's bytes go unchanged, so the + watch grades their freshness itself. The service keeps no session and no + Noise state after the answer. Limits: one read at a time per device and 30 + a minute, 16 at once per home, 64 at once in the process, message 1 at + most 4 KiB, the answer within 10 s. - Limits per home: 16 sessions, one per device, 256 envelopes queued per phone, and a small message rate from each phone. - **Alerts (C4).** A box's signed `event` goes to its phones through APNs, @@ -50,7 +64,10 @@ to it yet. Apple's key comes from Parameter Store ([deploy/apns.md](deploy/apns.md)). - **Widget push.** A widget registers its push token with `push.register - {kind: widgets}`, which needs `ftw.live.read`. When a box's prices reach + {kind: widgets}`, which needs `ftw.live.read`. A watch registers its + alert and widget tokens with `app: watch`; its pushes go to the watch + app's topic (`-apns-watch-topic`), and an alert to a watch is the same + payload, byte for byte, as to its phone. When a box's prices reach further than before, as when tomorrow's arrive, or a car is plugged in, the service sends Apple's widgets push: no data, only "content changed". The widgets then read `/v1/now`. At most 4 an hour per home, since Apple @@ -74,6 +91,7 @@ Not built yet: ``` box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory) ◀── store.For(principal) ◀── /v1/app ◀──Noise IK── phone + │ ◀── /v1/app/once ◀──Noise IK in one POST── watch │ ◀── /v1/now ◀──token──── widget └────▶ registry (SQLite: key, roster, tombstones, suspensions, APNs, token hashes) ``` @@ -87,9 +105,10 @@ box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory) principal reads only `live`, `prices` and `hb` within that same scope. There is no listing and no principal for staff; `TestNoRouteReadsAHome` checks every route. -- **A phone can narrow and nothing else.** It can say whether it is looking, - and an owner can suspend a device in the roster. Commands go from the app - to the box over the relay, never through here. +- **A phone or a watch can narrow and nothing else.** A phone can say + whether it is looking, and an owner can suspend a device in the roster. A + watch can read, ask for its token and register for push. Commands go to + the box over the relay, never through here. - **The service sends five things.** `challenge`, `hello_ok`, `watch`, `resync` and `purged` are the whole downlink, and none asks the box to act. The box closes the uplink on anything else. diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index be2e62c..2b47263 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -69,7 +69,8 @@ func serve(args []string) error { keyFile := fs.String("tls-key", "", "certificate key file, with -tls-cert") email := fs.String("acme-email", "", "contact for the certificate authority") apnsDir := fs.String("apns-dir", "/run/secrets/apns", "directory with Apple's key.p8, key-id and team-id; without it no alert is sent") - apnsTopic := fs.String("apns-topic", "energy.ftw.app", "the app's bundle id") + apnsTopic := fs.String("apns-topic", "energy.ftw.app", "the iPhone app's bundle id") + apnsWatch := fs.String("apns-watch-topic", "energy.ftw.app.watchkitapp", "the watch app's bundle id") devPush := fs.String("dev-push-listen", "", "loopback address for registering a token by hand (development builds only)") devCapture := fs.String("dev-apns-capture", "", "write each alert to this directory instead of sending it to Apple (development builds only)") _ = fs.Parse(args) @@ -119,6 +120,7 @@ func serve(args []string) error { sender = client } pushSvc := push.New(reg, sender, log) + pushSvc.WatchApp = *apnsWatch up.OnHeld = pushSvc.Held up.OnRoster = func(home string, r *cloudwire.Roster) { pushSvc.Roster(home, r) diff --git a/internal/appsession/appsession.go b/internal/appsession/appsession.go index 45b24f9..a5f0ec0 100644 --- a/internal/appsession/appsession.go +++ b/internal/appsession/appsession.go @@ -70,6 +70,12 @@ type Server struct { now func() time.Time // renewEvery is WatchRenew; tests shorten it. renewEvery time.Duration + // once counts the one-shot reads under way; onceMessage2 bounds their + // message 2, and onceEphemeral, when set, fixes their ephemeral key. + // Tests set the last two to replay golden vectors. + once onceLimits + onceMessage2 int + onceEphemeral *appwire.KeyPair // Push keeps the phones' APNs tokens. Nil ignores push.register and // push.unregister. Set before serving. @@ -110,7 +116,8 @@ func New(st *store.Store, reg *registry.Registry, w Watcher, key appwire.KeyPair log = slog.Default() } return &Server{store: st, registry: reg, watcher: w, key: key, log: log, now: time.Now, - renewEvery: WatchRenew, looking: map[string]*look{}} + renewEvery: WatchRenew, looking: map[string]*look{}, onceMessage2: maxNoiseMessage, + once: onceLimits{homes: map[string]int{}, devices: map[string]*onceDevice{}}} } var upgrader = websocket.Upgrader{ @@ -185,7 +192,7 @@ func (s *Server) serve(conn *websocket.Conn, home string) (int, error) { } defer sess.Close() - msg2Payload, rest, err := snapshotPayload(snap) + msg2Payload, rest, err := snapshotPayload(snap, maxNoiseMessage) if err != nil { return websocket.CloseInternalServerErr, err } @@ -227,9 +234,9 @@ func parseNow(payload []byte) (int, error) { return now.Hz, nil } -// snapshotPayload is message 2. Envelopes that do not fit in one Noise -// message come back as rest, to follow as frames. -func snapshotPayload(snap store.Snapshot) ([]byte, [][]byte, error) { +// snapshotPayload is message 2. Envelopes that do not fit in a message 2 of +// at most limit bytes come back as rest, to follow as frames. +func snapshotPayload(snap store.Snapshot, limit int) ([]byte, [][]byte, error) { envs := snap.Envelopes var rest [][]byte for { @@ -237,7 +244,7 @@ func snapshotPayload(snap store.Snapshot) ([]byte, [][]byte, error) { if err != nil { return nil, nil, err } - if len(payload)+appwire.Message2Overhead <= maxNoiseMessage || len(envs) == 0 { + if len(payload)+appwire.Message2Overhead <= limit || len(envs) == 0 { return payload, rest, nil } rest = append([][]byte{envs[len(envs)-1]}, rest...) diff --git a/internal/appsession/fuzz_test.go b/internal/appsession/fuzz_test.go index 67cd253..dd368e7 100644 --- a/internal/appsession/fuzz_test.go +++ b/internal/appsession/fuzz_test.go @@ -76,3 +76,56 @@ func mustFrame(t string, body any) []byte { } return frame } + +// Whatever message 1 of a one-shot read carries, the service neither panics +// nor does more than the protocol allows: it answers or refuses with a +// status the protocol names, a viewer suspends nobody, and nothing stays +// counted after the answer. +func FuzzOnceMessage(f *testing.F) { + h := newHarness(f) + h.live(true) + for _, seed := range []struct { + t string + body any + }{ + {cloudwire.MsgRead, cloudwire.Read{Hz: 1}}, + {cloudwire.MsgRead, cloudwire.Read{Have: cloudwire.Have{Boot: h.boot, Seq: map[string]uint64{"live": 9}}}}, + {cloudwire.MsgTokenGet, struct{}{}}, + {cloudwire.MsgPushRegister, cloudwire.PushRegister{APNsToken: []byte{1}, Environment: "sandbox", Kind: "alert", App: "watch"}}, + {cloudwire.MsgPushUnregister, cloudwire.PushUnregister{APNsToken: []byte{1}}}, + {cloudwire.MsgSuspend, cloudwire.Suspend{DeviceID: "owner"}}, + } { + payload, _ := cloudwire.Unsigned(seed.t, seed.body) + f.Add(payload) + } + f.Add([]byte{0xa0}) + f.Add([]byte{}) + allowed := map[int]bool{200: true, 400: true, 403: true, 429: true, 501: true} + f.Fuzz(func(t *testing.T, payload []byte) { + h.app.once = onceLimits{homes: map[string]int{}, devices: map[string]*onceDevice{}} + init, err := appwire.NewInitiator(appwire.InitiatorOptions{StaticKey: h.viewer, RemoteStatic: h.noise.Public(), + Prologue: cloudwire.OncePrologue(h.home)}) + if err != nil { + t.Fatal(err) + } + msg1, err := init.WriteMessage(payload) + if err != nil { + return // larger than one Noise message: never reaches the service + } + body, status := h.app.answerOnce(h.home, msg1) + if !allowed[status] { + t.Fatalf("status %d", status) + } + if status == 200 { + if _, err := cloudwire.SplitOnceAnswer(body); err != nil { + t.Fatal(err) + } + } + if h.reg.Suspended(h.home, "owner", 1) { + t.Fatal("a viewer's one-shot read suspended the owner") + } + if open, _ := h.app.once.held(); open != 0 { + t.Fatal("a read is still counted after its answer") + } + }) +} diff --git a/internal/appsession/harness_test.go b/internal/appsession/harness_test.go index 7386374..a462c4c 100644 --- a/internal/appsession/harness_test.go +++ b/internal/appsession/harness_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -43,6 +44,8 @@ type harness struct { seq uint64 rosterN uint64 boot []byte + // shift moves the store's clock, in nanoseconds. + shift *atomic.Int64 } type watchCount struct { @@ -69,12 +72,16 @@ func newHarness(t testing.TB) *harness { t.Fatal(err) } t.Cleanup(func() { _ = reg.Close() }) - st := store.New(nil) + shift := &atomic.Int64{} + st := store.New(func() time.Time { return time.Now().Add(time.Duration(shift.Load())) }) st.UseSuspensions(reg.Suspended) noise, _ := appwire.GenerateKeyPair() w := &watchCount{} app := New(st, reg, w, noise, slog.New(slog.NewTextHandler(io.Discard, nil))) - srv := httptest.NewUnstartedServer(app) + mux := http.NewServeMux() + mux.Handle("/v1/app", app) + mux.HandleFunc("/v1/app/once", app.ServeOnce) + srv := httptest.NewUnstartedServer(mux) srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} srv.StartTLS() t.Cleanup(srv.Close) @@ -93,7 +100,7 @@ func newHarness(t testing.TB) *harness { owner, _ := appwire.GenerateKeyPair() viewer, _ := appwire.GenerateKeyPair() h := &harness{t: t, store: st, reg: reg, app: app, srv: srv, watch: w, noise: noise, boxKey: boxKey, - home: home, box: box, owner: owner, viewer: viewer, boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes)} + home: home, box: box, owner: owner, viewer: viewer, boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes), shift: shift} h.setRoster(h.devices(1, 1), protocol.ScopeLiveRead, protocol.ScopePlanRead) return h } diff --git a/internal/appsession/once.go b/internal/appsession/once.go new file mode 100644 index 0000000..40ff335 --- /dev/null +++ b/internal/appsession/once.go @@ -0,0 +1,300 @@ +package appsession + +import ( + "crypto/tls" + "io" + "mime" + "net/http" + "slices" + "sync" + "time" + + "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// The one-shot read at /v1/app/once: a watch reads its home in one HTTPS +// request, because watchOS gives an app HTTPS and nothing lower (ADR 0009, +// decision 7). docs/protocol-cloud.md in srcfl/ftw, "One-shot read", is the +// wire format. +// +// The body is Noise IK message 1 to the service's Noise key, with +// cloudwire.OncePrologue(home) as the prologue. It carries one unsigned +// envelope: read, token.get, push.register or push.unregister. The service +// admits the device as /v1/app admits a phone, through store.For with the +// key the handshake proved, and answers with message 2 and, for a read, the +// envelopes that did not fit, each after its length. It keeps no session and +// no Noise state after the answer; only the counts that bound how often a +// device, a home and the process may read. + +const ( + // MaxOnceInFlight bounds the reads the process answers at once, counted + // after their bodies are in. Each costs four X25519 operations. + MaxOnceInFlight = 64 + // MaxOncePerHome bounds one home's reads at once, as its sessions are + // bounded. + MaxOncePerHome = store.MaxSessionsPerHome +) + +// onceLimits counts the one-shot reads under way and when each device's +// recent reads began. It holds no session and no Noise state. +type onceLimits struct { + mu sync.Mutex + process int + homes map[string]int + devices map[string]*onceDevice + swept time.Time +} + +// onceDevice is one device's reads: whether one is open, and when the reads +// of the last minute began. +type onceDevice struct { + open bool + at []time.Time +} + +func (l *onceLimits) enterProcess() bool { + l.mu.Lock() + defer l.mu.Unlock() + if l.process >= MaxOnceInFlight { + return false + } + l.process++ + return true +} + +func (l *onceLimits) leaveProcess() { + l.mu.Lock() + defer l.mu.Unlock() + l.process-- +} + +// enter starts one admitted device's read, or reports a limit it would pass: +// one read at a time per device and cloudwire.OnceReadsPerMinute, and +// MaxOncePerHome at once per home. +func (l *onceLimits) enter(home string, key []byte, now time.Time) (release func(), ok bool) { + l.mu.Lock() + defer l.mu.Unlock() + if now.Sub(l.swept) > time.Minute { + for k, d := range l.devices { + if !d.open && (len(d.at) == 0 || now.Sub(d.at[len(d.at)-1]) > time.Minute) { + delete(l.devices, k) + } + } + l.swept = now + } + id := home + "\x00" + string(key) + d := l.devices[id] + if d == nil { + d = &onceDevice{} + l.devices[id] = d + } + d.at = slices.DeleteFunc(d.at, func(at time.Time) bool { return now.Sub(at) >= time.Minute }) + if d.open || len(d.at) >= cloudwire.OnceReadsPerMinute || l.homes[home] >= MaxOncePerHome { + return nil, false + } + d.open = true + d.at = append(d.at, now) + l.homes[home]++ + return func() { + l.mu.Lock() + defer l.mu.Unlock() + d.open = false + if l.homes[home]--; l.homes[home] <= 0 { + delete(l.homes, home) + } + }, true +} + +// held counts what the limits hold: reads under way and devices known. +func (l *onceLimits) held() (open, devices int) { + l.mu.Lock() + defer l.mu.Unlock() + for _, n := range l.homes { + open += n + } + return open + l.process, len(l.devices) +} + +// ServeOnce is /v1/app/once. Every refusal has an empty body. +func (s *Server) ServeOnce(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.TLS == nil || r.TLS.Version != tls.VersionTLS13 { + http.Error(w, "TLS 1.3 only", http.StatusUpgradeRequired) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + home := r.URL.Query().Get(cloudwire.AppHomeParam) + if !cloudwire.ValidHome(home) { + w.WriteHeader(http.StatusBadRequest) + return + } + // The watch sends no Origin. A browser always does, and has no business + // here. + if r.Header.Get("Origin") != "" { + w.WriteHeader(http.StatusForbidden) + return + } + if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/octet-stream" { + w.WriteHeader(http.StatusUnsupportedMediaType) + return + } + rc := http.NewResponseController(w) + deadline := time.Now().Add(cloudwire.OnceAnswerWithin) + _ = rc.SetReadDeadline(deadline) + _ = rc.SetWriteDeadline(deadline) + msg1, err := io.ReadAll(io.LimitReader(r.Body, cloudwire.OnceMessage1MaxBytes+1)) + if err != nil || len(msg1) == 0 || len(msg1) > cloudwire.OnceMessage1MaxBytes { + w.WriteHeader(http.StatusBadRequest) + return + } + if !s.once.enterProcess() { + w.WriteHeader(http.StatusTooManyRequests) + return + } + defer s.once.leaveProcess() + body, status := s.answerOnce(home, msg1) + if status != http.StatusOK { + s.log.Debug("one-shot read refused", "status", status) + w.WriteHeader(status) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(body) +} + +// answerOnce runs the handshake and returns the answer body, or the status +// that refuses it. +func (s *Server) answerOnce(home string, msg1 []byte) ([]byte, int) { + hs, err := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: s.key, Prologue: cloudwire.OncePrologue(home), + Ephemeral: s.onceEphemeral}) + if err != nil { + return nil, http.StatusInternalServerError + } + payload, err := hs.ReadMessage(msg1) + if err != nil { + // Another service key, or another home in the prologue than in the + // query. Neither says anything about the home. + return nil, http.StatusForbidden + } + env, err := cloudwire.DecodeEnvelope(payload) + if err != nil || len(env.Sig) != 0 || !slices.Contains(cloudwire.OnceMessages, env.T) { + return nil, http.StatusBadRequest + } + dev, err := s.store.For(store.Principal{Kind: store.Device, Home: home, DeviceKey: hs.RemoteStatic()}) + if err != nil { + return nil, http.StatusForbidden + } + release, ok := s.once.enter(home, hs.RemoteStatic(), s.now()) + if !ok { + return nil, http.StatusTooManyRequests + } + defer release() + + reply, rest, status := s.onceReply(home, dev, env) + if status != http.StatusOK { + return nil, status + } + msg2, err := hs.WriteMessage(reply) + if err != nil { + return nil, http.StatusInternalServerError + } + messages := [][]byte{msg2} + if len(rest) > 0 { + keys, err := hs.Split() + if err != nil { + return nil, http.StatusInternalServerError + } + tr := appwire.NewTransport(keys) + for _, e := range rest { + frame, err := cloudwire.EncodeFrame(e) + if err != nil { + return nil, http.StatusInternalServerError + } + sealed, err := tr.Encrypt(frame) + if err != nil { + return nil, http.StatusInternalServerError + } + messages = append(messages, sealed) + } + } + body, err := cloudwire.OnceAnswer(messages...) + if err != nil { + return nil, http.StatusInternalServerError + } + return body, http.StatusOK +} + +// onceReply is message 2's payload and, for a read, the envelopes to follow +// it. dev is the roster entry store.For matched, never anything the message +// says. +func (s *Server) onceReply(home string, dev *store.Handle, env cloudwire.Envelope) ([]byte, [][]byte, int) { + switch env.T { + case cloudwire.MsgRead: + var rd cloudwire.Read + if err := cloudwire.Unmarshal(env.B, &rd); err != nil { + return nil, nil, http.StatusBadRequest + } + snap, err := dev.Snapshot() + if err != nil { + return nil, nil, http.StatusForbidden + } + // What the watch holds stays out, and so does an envelope too large + // to follow in one Noise message, so more counts exactly what + // follows. + snap.Envelopes = slices.DeleteFunc(snap.Envelopes, func(e []byte) bool { + return rd.Have.Covers(e) || cloudwire.HeaderBytes+len(e)+appwire.TransportOverhead > maxNoiseMessage + }) + payload, rest, err := snapshotPayload(snap, s.onceMessage2) + if err != nil { + return nil, nil, http.StatusInternalServerError + } + if rd.Hz >= cloudwire.HzLooking { + s.watcher.Watch(home) + } + return payload, rest, http.StatusOK + case cloudwire.MsgTokenGet: + if s.Tokens == nil { + return nil, nil, http.StatusNotImplemented + } + tok, err := s.Tokens.Issue(home, dev.Device()) + if err != nil { + return nil, nil, http.StatusInternalServerError + } + payload, err := cloudwire.Unsigned(cloudwire.MsgToken, tok) + if err != nil { + return nil, nil, http.StatusInternalServerError + } + return payload, nil, http.StatusOK + case cloudwire.MsgPushRegister, cloudwire.MsgPushUnregister: + if s.Push == nil { + return nil, nil, http.StatusNotImplemented + } + var err error + if env.T == cloudwire.MsgPushRegister { + var r cloudwire.PushRegister + if cloudwire.Unmarshal(env.B, &r) != nil { + return nil, nil, http.StatusBadRequest + } + err = s.Push.Register(home, dev.Device(), dev.Scopes(), r) + } else { + var r cloudwire.PushUnregister + if cloudwire.Unmarshal(env.B, &r) != nil { + return nil, nil, http.StatusBadRequest + } + err = s.Push.Unregister(home, dev.Device(), r) + } + // A registration the device may not make stores nothing and is + // answered as the app session answers it: without a word. + if pushed(err) != nil { + return nil, nil, http.StatusInternalServerError + } + return nil, nil, http.StatusOK + } + return nil, nil, http.StatusBadRequest +} diff --git a/internal/appsession/once_test.go b/internal/appsession/once_test.go new file mode 100644 index 0000000..f75d104 --- /dev/null +++ b/internal/appsession/once_test.go @@ -0,0 +1,695 @@ +package appsession + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/push" + "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw-cloud/internal/token" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// onceOpts is one one-shot read as a watch would post it, with the knobs a +// test turns. +type onceOpts struct { + key appwire.KeyPair + service []byte // the service key the watch pins; nil uses the harness's + home string // query; "" uses the harness's + prologue string // home in the prologue; "" uses query + t string // message 1's type; "" is read + body any // message 1's body; nil is read{hz: 0} + // raw, when set, is posted instead of a handshake. + raw []byte + // header overrides, for the HTTP rules. + method, contentType, origin string +} + +// onceAnswer is what the watch makes of the answer. +type onceAnswer struct { + status int + // body is the raw answer, payload message 2's payload, and following + // the envelopes after it, each as the box sent it. + body []byte + payload []byte + following [][]byte +} + +// once posts one one-shot read and opens the answer as the watch does. +func (h *harness) once(o onceOpts) onceAnswer { + h.t.Helper() + if o.service == nil { + o.service = h.noise.Public() + } + if o.home == "" { + o.home = h.home + } + if o.prologue == "" { + o.prologue = o.home + } + if o.t == "" { + o.t = cloudwire.MsgRead + } + if o.body == nil { + o.body = cloudwire.Read{Hz: cloudwire.HzAway} + } + if o.method == "" { + o.method = http.MethodPost + } + if o.contentType == "" { + o.contentType = "application/octet-stream" + } + init, err := appwire.NewInitiator(appwire.InitiatorOptions{StaticKey: o.key, RemoteStatic: o.service, + Prologue: cloudwire.OncePrologue(o.prologue)}) + if err != nil { + h.t.Fatal(err) + } + msg1 := o.raw + if msg1 == nil { + payload, err := cloudwire.Unsigned(o.t, o.body) + if err != nil { + h.t.Fatal(err) + } + if msg1, err = init.WriteMessage(payload); err != nil { + h.t.Fatal(err) + } + } + req, _ := http.NewRequest(o.method, h.srv.URL+cloudwire.OncePath+"?"+cloudwire.AppHomeParam+"="+o.home, bytes.NewReader(msg1)) + req.Header.Set("Content-Type", o.contentType) + if o.origin != "" { + req.Header.Set("Origin", o.origin) + } + resp, err := h.srv.Client().Do(req) + if err != nil { + h.t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + a := onceAnswer{status: resp.StatusCode, body: body} + if resp.StatusCode != http.StatusOK { + if len(body) != 0 { + h.t.Fatalf("a %d said %q", resp.StatusCode, body) + } + return a + } + if resp.Header.Get("Content-Type") != "application/octet-stream" || resp.Header.Get("Cache-Control") != "no-store" { + h.t.Fatalf("answer headers %v", resp.Header) + } + parts, err := cloudwire.SplitOnceAnswer(body) + if err != nil { + h.t.Fatal(err) + } + if a.payload, err = init.ReadMessage(parts[0]); err != nil { + h.t.Fatalf("message 2: %v", err) + } + keys, _ := init.Split() + tr := appwire.NewTransport(keys) + for _, m := range parts[1:] { + frame, err := tr.Decrypt(m) + if err != nil { + h.t.Fatal(err) + } + env, err := cloudwire.DecodeFrame(frame) + if err != nil { + h.t.Fatal(err) + } + a.following = append(a.following, env) + } + return a +} + +// snapshot reads a read's message 2 and joins what followed it. +func (a onceAnswer) snapshot(t *testing.T) (cloudwire.Snapshot, [][]byte) { + t.Helper() + snap, err := cloudwire.ParseSnapshot(a.payload) + if err != nil { + t.Fatal(err) + } + if snap.More != len(a.following) { + t.Fatalf("more says %d, %d followed", snap.More, len(a.following)) + } + all := make([][]byte, 0, len(snap.Envelopes)+len(a.following)) + for _, e := range snap.Envelopes { + all = append(all, e) + } + return snap, append(all, a.following...) +} + +// addWatch puts a watch under the owner in the roster, as the box lists +// one: role watch and its own key, with the scopes the box derives. +func (h *harness) addWatch(watch appwire.KeyPair, grant ...string) { + devices := append(h.devices(1, 1), cloudwire.Device{ID: "watch", Key: watch.Public(), Role: "watch", + Scopes: []string{protocol.ScopeLiveRead, protocol.ScopePlanRead, protocol.ScopeEventsRead}, Epoch: 1}) + h.setRoster(devices, grant...) +} + +// A read answers with the app session's snapshot, as the box's own bytes, +// less what the watch already holds. +func TestAOnceReadCarriesTheBoxsBytesLessWhatTheWatchHolds(t *testing.T) { + h := newHarness(t) + watch, _ := appwire.GenerateKeyPair() + h.addWatch(watch, protocol.ScopeLiveRead, protocol.ScopePlanRead) + full := h.live(true) + plan := h.plan() + delta := h.live(false) + hb := h.hb() + + a := h.once(onceOpts{key: watch}) + if a.status != http.StatusOK { + t.Fatalf("read: %d", a.status) + } + snap, all := a.snapshot(t) + if !snap.Online || snap.LastSeenMs == 0 { + t.Fatalf("snapshot %+v", snap) + } + want := [][]byte{full, plan, delta, hb} + if len(all) != len(want) { + t.Fatalf("read %d envelopes", len(all)) + } + for i := range want { + if !bytes.Equal(all[i], want[i]) { + t.Fatalf("envelope %d is not the box's bytes", i) + } + verify(t, &h.boxKey.PublicKey, h.home, all[i]) + } + + // The watch holds the full live and the plan: only what is newer comes. + have := cloudwire.Have{Boot: h.boot, Seq: map[string]uint64{cloudwire.MsgLive: 1, cloudwire.MsgPlan: 2}} + a = h.once(onceOpts{key: watch, body: cloudwire.Read{Have: have}}) + if _, all = a.snapshot(t); len(all) != 2 || !bytes.Equal(all[0], delta) || !bytes.Equal(all[1], hb) { + t.Fatalf("with have, read %d envelopes", len(all)) + } + // A have from another boot covers nothing. + have.Boot = bytes.Repeat([]byte{0xb1}, cloudwire.BootBytes) + if _, all = h.once(onceOpts{key: watch, body: cloudwire.Read{Have: have}}).snapshot(t); len(all) != 4 { + t.Fatalf("a have from another boot left out %d", 4-len(all)) + } +} + +// Envelopes that do not fit in message 2 follow it, in rising seq. +func TestAOnceReadSpillsIntoTransportMessages(t *testing.T) { + h := newHarness(t) + watch, _ := appwire.GenerateKeyPair() + h.addWatch(watch, protocol.ScopeLiveRead) + full := h.live(true) + delta := h.live(false) + hb := h.hb() + probe, _ := cloudwire.SnapshotPayload([][]byte{full}, 0, true, 2) + h.app.onceMessage2 = len(probe) + appwire.Message2Overhead + 20 + a := h.once(onceOpts{key: watch}) + snap, all := a.snapshot(t) + if len(snap.Envelopes) != 1 || len(a.following) != 2 || !bytes.Equal(all[1], delta) || !bytes.Equal(all[2], hb) { + t.Fatalf("message 2 held %d, %d followed", len(snap.Envelopes), len(a.following)) + } +} + +// ADR 0009: the one-shot read admits exactly the devices /v1/app admits, +// through the same table of cases. +func TestOnceAdmitsExactlyWhatTheAppSessionAdmits(t *testing.T) { + stranger, _ := appwire.GenerateKeyPair() + otherService, _ := appwire.GenerateKeyPair() + const otherHome = "aaaaaaaaaaaaaaaaaaaaaaaaaa" + type kase struct { + name string + setup func(h *harness, watch appwire.KeyPair) (appwire.KeyPair, dialOpts) + admit bool + } + plain := func(pick func(h *harness, watch appwire.KeyPair) appwire.KeyPair) func(*harness, appwire.KeyPair) (appwire.KeyPair, dialOpts) { + return func(h *harness, watch appwire.KeyPair) (appwire.KeyPair, dialOpts) { + k := pick(h, watch) + return k, dialOpts{key: k} + } + } + cases := []kase{ + {"the owner", plain(func(h *harness, _ appwire.KeyPair) appwire.KeyPair { return h.owner }), true}, + {"a viewer", plain(func(h *harness, _ appwire.KeyPair) appwire.KeyPair { return h.viewer }), true}, + {"a watch", plain(func(_ *harness, w appwire.KeyPair) appwire.KeyPair { return w }), true}, + {"a key the roster lacks", plain(func(*harness, appwire.KeyPair) appwire.KeyPair { return stranger }), false}, + {"another service key", func(_ *harness, w appwire.KeyPair) (appwire.KeyPair, dialOpts) { + return w, dialOpts{key: w, service: otherService.Public()} + }, false}, + {"another home in the prologue", func(_ *harness, w appwire.KeyPair) (appwire.KeyPair, dialOpts) { + return w, dialOpts{key: w, prologue: otherHome} + }, false}, + {"a home the service lacks", func(_ *harness, w appwire.KeyPair) (appwire.KeyPair, dialOpts) { + return w, dialOpts{key: w, home: otherHome} + }, false}, + {"a suspended watch", func(h *harness, w appwire.KeyPair) (appwire.KeyPair, dialOpts) { + _ = h.reg.Suspend(h.home, "watch", 1, time.Now()) + return w, dialOpts{key: w} + }, false}, + {"a watch the roster dropped", func(h *harness, w appwire.KeyPair) (appwire.KeyPair, dialOpts) { + h.setRoster(h.devices(1, 1), protocol.ScopeLiveRead) + return w, dialOpts{key: w} + }, false}, + {"a roster that ran out", func(h *harness, w appwire.KeyPair) (appwire.KeyPair, dialOpts) { + h.shift.Store(int64(24*time.Hour + time.Second)) + return w, dialOpts{key: w} + }, false}, + {"a purged home", func(h *harness, w appwire.KeyPair) (appwire.KeyPair, dialOpts) { + h.box.Delete() + return w, dialOpts{key: w} + }, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t) + watch, _ := appwire.GenerateKeyPair() + h.addWatch(watch, protocol.ScopeLiveRead) + h.live(true) + key, o := c.setup(h, watch) + _, code, err := h.dial(o) + if err != nil { + t.Fatal(err) + } + a := h.once(onceOpts{key: key, service: o.service, home: o.home, prologue: o.prologue}) + appAdmits, onceAdmits := code == 0, a.status == http.StatusOK + if appAdmits != c.admit || onceAdmits != c.admit { + t.Fatalf("/v1/app closed with %d, /v1/app/once said %d; want admitted = %v", code, a.status, c.admit) + } + if !c.admit && (code != cloudwire.CloseDenied || a.status != http.StatusForbidden) { + t.Fatalf("refusals: %d and %d", code, a.status) + } + }) + } +} + +// ADR 0009: a watch the roster drops is refused within a second of that +// roster arriving. +func TestAWatchTheRosterDropsIsRefusedWithinASecond(t *testing.T) { + h := newHarness(t) + watch, _ := appwire.GenerateKeyPair() + h.addWatch(watch, protocol.ScopeLiveRead) + h.live(true) + if a := h.once(onceOpts{key: watch}); a.status != http.StatusOK { + t.Fatalf("before: %d", a.status) + } + arrived := time.Now() + h.setRoster(h.devices(1, 1), protocol.ScopeLiveRead) + if a := h.once(onceOpts{key: watch}); a.status != http.StatusForbidden { + t.Fatalf("after the roster dropped it: %d", a.status) + } + if took := time.Since(arrived); took > time.Second { + t.Fatalf("the refusal came %v after the roster", took) + } +} + +// Message 1 may carry read, token.get, push.register and push.unregister. +// Everything else, an owner's suspend among them, gets 400 before message 2, +// and so does a signed envelope. +func TestOnceRefusesOtherMessages(t *testing.T) { + h := newHarness(t) + h.live(true) + for _, c := range []struct { + t string + body any + }{ + {cloudwire.MsgSuspend, cloudwire.Suspend{DeviceID: "viewer"}}, + {cloudwire.MsgUnsuspend, cloudwire.Suspend{DeviceID: "viewer"}}, + {cloudwire.MsgNow, cloudwire.Now{Hz: 1}}, + {cloudwire.MsgWatch, cloudwire.Watch{Hz: 1}}, + {"tiles.get", map[string]any{"have": map[string]string{}}}, + } { + if a := h.once(onceOpts{key: h.owner, t: c.t, body: c.body}); a.status != http.StatusBadRequest { + t.Errorf("%s: %d", c.t, a.status) + } + } + if h.reg.Suspended(h.home, "viewer", 1) { + t.Fatal("a one-shot read suspended a device") + } + init, _ := appwire.NewInitiator(appwire.InitiatorOptions{StaticKey: h.owner, RemoteStatic: h.noise.Public(), + Prologue: cloudwire.OncePrologue(h.home)}) + b, _ := cloudwire.Marshal(cloudwire.Read{}) + signed, _ := cloudwire.Envelope{T: cloudwire.MsgRead, B: b, Sig: bytes.Repeat([]byte{1}, 64)}.Encode() + msg1, _ := init.WriteMessage(signed) + if a := h.once(onceOpts{key: h.owner, raw: msg1}); a.status != http.StatusBadRequest { + t.Fatalf("a signed message 1: %d", a.status) + } + if a := h.once(onceOpts{key: h.owner, body: "not a read"}); a.status != http.StatusBadRequest { + t.Fatalf("an unreadable read: %d", a.status) + } +} + +func TestOnceHTTPRules(t *testing.T) { + h := newHarness(t) + h.live(true) + for name, c := range map[string]struct { + o onceOpts + want int + }{ + "GET": {onceOpts{key: h.owner, method: http.MethodGet}, http.StatusMethodNotAllowed}, + "a browser's Origin": {onceOpts{key: h.owner, origin: "https://example.com"}, http.StatusForbidden}, + "another content type": {onceOpts{key: h.owner, contentType: "application/json"}, http.StatusUnsupportedMediaType}, + "no home": {onceOpts{key: h.owner, home: "not-a-home"}, http.StatusBadRequest}, + "an empty body": {onceOpts{key: h.owner, raw: []byte{}}, http.StatusBadRequest}, + "message 1 over 4 KiB": {onceOpts{key: h.owner, raw: make([]byte, cloudwire.OnceMessage1MaxBytes+1)}, http.StatusBadRequest}, + "noise garbage": {onceOpts{key: h.owner, raw: bytes.Repeat([]byte{7}, 200)}, http.StatusForbidden}, + } { + if a := h.once(c.o); a.status != c.want { + t.Errorf("%s: %d, want %d", name, a.status, c.want) + } + } + // A large message 1 that still fits is read. + big := cloudwire.Read{Have: cloudwire.Have{Boot: h.boot, Seq: map[string]uint64{}}} + for i := range 150 { + big.Have.Seq[strings.Repeat("x", 10)+string(rune('a'+i%26))+string(rune('a'+i/26))] = 1 + } + if a := h.once(onceOpts{key: h.owner, body: big}); a.status != http.StatusOK { + t.Fatalf("a 3 KiB message 1: %d", a.status) + } +} + +// One read at a time per device and 30 a minute; past that, 429. Another +// device of the home is not held back. +func TestOnceLimitsADevicesReads(t *testing.T) { + h := newHarness(t) + h.live(true) + for i := range cloudwire.OnceReadsPerMinute { + if a := h.once(onceOpts{key: h.owner}); a.status != http.StatusOK { + t.Fatalf("read %d: %d", i, a.status) + } + } + if a := h.once(onceOpts{key: h.owner}); a.status != http.StatusTooManyRequests { + t.Fatalf("read 31 in a minute: %d", a.status) + } + if a := h.once(onceOpts{key: h.viewer}); a.status != http.StatusOK { + t.Fatalf("another device: %d", a.status) + } +} + +func TestOnceLimitsCountInFlight(t *testing.T) { + l := onceLimits{homes: map[string]int{}, devices: map[string]*onceDevice{}} + now := time.Unix(1_791_820_800, 0) + key := func(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } + release, ok := l.enter("h1", key(1), now) + if !ok { + t.Fatal("the first read was refused") + } + if _, ok := l.enter("h1", key(1), now); ok { + t.Fatal("a device had two reads open") + } + release() + if r, ok := l.enter("h1", key(1), now); !ok { + t.Fatal("a device could not read after its read ended") + } else { + r() + } + var open []func() + for i := range MaxOncePerHome { + r, ok := l.enter("h1", key(byte(10+i)), now) + if !ok { + t.Fatalf("read %d of the home refused", i) + } + open = append(open, r) + } + if _, ok := l.enter("h1", key(99), now); ok { + t.Fatal("a home had more reads open than it may") + } + if r, ok := l.enter("h2", key(99), now); !ok { + t.Fatal("one home's reads held back another's") + } else { + r() + } + for _, r := range open { + r() + } + for range MaxOnceInFlight { + if !l.enterProcess() { + t.Fatal("the process refused below its limit") + } + } + if l.enterProcess() { + t.Fatal("the process took more reads than it may") + } + for range MaxOnceInFlight { + l.leaveProcess() + } + if inFlight, _ := l.held(); inFlight != 0 { + t.Fatalf("%d reads still counted", inFlight) + } + // A minute later the devices that read are forgotten. + r, _ := l.enter("h3", key(1), now.Add(2*time.Minute)) + r() + if _, devices := l.held(); devices != 1 { + t.Fatalf("%d devices still known", devices) + } +} + +// ADR 0009: after an answer the service holds no session and no Noise state +// for that read. A home's sessions are all still there to open. +func TestOnceKeepsNothingAfterTheAnswer(t *testing.T) { + h := newHarness(t) + h.live(true) + for range 5 { + if a := h.once(onceOpts{key: h.owner, body: cloudwire.Read{Hz: cloudwire.HzLooking}}); a.status != http.StatusOK { + t.Fatalf("read: %d", a.status) + } + } + if inFlight, _ := h.app.once.held(); inFlight != 0 { + t.Fatalf("%d reads still counted after their answers", inFlight) + } + h.app.mu.Lock() + looking := len(h.app.looking) + h.app.mu.Unlock() + if looking != 0 { + t.Fatal("a one-shot read left the home looked at") + } + keys := make([]appwire.KeyPair, store.MaxSessionsPerHome) + devices := h.devices(1, 1) + for i := range keys { + keys[i], _ = appwire.GenerateKeyPair() + devices = append(devices, cloudwire.Device{ID: "phone-" + string(rune('a'+i)), Key: keys[i].Public(), + Role: "viewer", Scopes: []string{protocol.ScopeLiveRead}, Epoch: 1}) + } + h.setRoster(devices, protocol.ScopeLiveRead) + for i, k := range keys { + x, err := h.store.For(store.Principal{Kind: store.Device, Home: h.home, DeviceKey: k.Public()}) + if err != nil { + t.Fatal(err) + } + _, sess, err := x.Open() + if err != nil { + t.Fatalf("session %d: %v", i, err) + } + defer sess.Close() + } +} + +// hz 1 asks the box for one-second updates, as a looking phone does; hz 0 +// leaves it as it is. +func TestOnceHzAsksTheBoxToHurry(t *testing.T) { + h := newHarness(t) + h.live(true) + h.once(onceOpts{key: h.owner}) + if h.watch.count() != 0 { + t.Fatal("hz 0 asked the box") + } + h.once(onceOpts{key: h.owner, body: cloudwire.Read{Hz: cloudwire.HzLooking}}) + if h.watch.count() != 1 { + t.Fatalf("hz 1 asked the box %d times", h.watch.count()) + } + h.once(onceOpts{key: h.owner, home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", body: cloudwire.Read{Hz: cloudwire.HzLooking}}) + if h.watch.count() != 1 { + t.Fatal("a refused read asked a box") + } +} + +// Freshness stays the box's: an hour after the box went quiet, a read +// carries when it was last seen and no readings. +func TestAOnceReadAfterAnHourWithoutTheBoxCarriesNoReadings(t *testing.T) { + h := newHarness(t) + h.live(true) + h.hb() + gen := h.box.Connected() + h.box.Disconnected(gen) + h.shift.Store(int64(store.ReadingsKeptFor + time.Second)) + snap, all := h.once(onceOpts{key: h.owner}).snapshot(t) + if len(all) != 0 || snap.Online || snap.LastSeenMs == 0 { + t.Fatalf("after an hour: %d envelopes, online %v, last seen %d", len(all), snap.Online, snap.LastSeenMs) + } +} + +// ADR 0009: a watch gets its own token over the one-shot read. It reads +// live, prices and hb, and nothing else. +func TestAWatchTokenReadsLivePricesAndHB(t *testing.T) { + h := newHarness(t) + watch, _ := appwire.GenerateKeyPair() + h.addWatch(watch, protocol.CloudScopes...) + read := h.tokens(t) + full := h.live(true) + h.plan() + prices := h.put(cloudwire.MsgPrices, func(hdr cloudwire.Header) any { + return cloudwire.Prices{Header: hdr, SpotMinor: []*int64{}, TotalMinor: []*int64{}} + }, false) + h.put(cloudwire.MsgLP, func(hdr cloudwire.Header) any { + return cloudwire.LP{Header: hdr, Loadpoints: []cloudwire.Loadpoint{}, Feedback: []cloudwire.Feedback{}} + }, false) + h.put(cloudwire.MsgEvent, func(hdr cloudwire.Header) any { + return cloudwire.Event{Header: hdr, EventID: "e", Kind: "k", Severity: cloudwire.SeverityInfo, Args: map[string]string{}} + }, false) + hb := h.hb() + + a := h.once(onceOpts{key: watch, t: cloudwire.MsgTokenGet, body: struct{}{}}) + if a.status != http.StatusOK || len(a.following) != 0 { + t.Fatalf("token.get: %d", a.status) + } + env, err := cloudwire.DecodeEnvelope(a.payload) + if err != nil || env.T != cloudwire.MsgToken || len(env.Sig) != 0 { + t.Fatalf("message 2 is %q", env.T) + } + var tok cloudwire.Token + if err := cloudwire.Unmarshal(env.B, &tok); err != nil || tok.ExpiresInS != int64(token.Life/time.Second) { + t.Fatalf("token %+v %v", tok, err) + } + code, body := read(tok.Token) + if code != http.StatusOK { + t.Fatalf("the watch's token read %d", code) + } + var now token.Now + if err := cloudwire.Unmarshal(body, &now); err != nil { + t.Fatal(err) + } + want := [][]byte{full, prices, hb} + if len(now.Envelopes) != len(want) { + t.Fatalf("the watch's token read %d envelopes", len(now.Envelopes)) + } + for i := range want { + if !bytes.Equal(now.Envelopes[i], want[i]) { + t.Fatalf("envelope %d is not the box's", i) + } + } + // The watch's token is its own: the owner's next token leaves it be. + if _, err := h.app.Tokens.Issue(h.home, h.devices(1, 1)[0]); err != nil { + t.Fatal(err) + } + if code, _ := read(tok.Token); code != http.StatusOK { + t.Fatalf("the owner's new token killed the watch's: %d", code) + } +} + +// A watch registers its APNs tokens with app watch over the one-shot read, +// and message 2 is empty once the change is stored. +func TestAWatchRegistersForPushOverOnce(t *testing.T) { + h := newHarness(t) + watch, _ := appwire.GenerateKeyPair() + h.addWatch(watch, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + h.app.Push = push.New(h.reg, nil, nil) + for _, kind := range []string{cloudwire.PushKindAlert, push.KindWidgets} { + a := h.once(onceOpts{key: watch, t: cloudwire.MsgPushRegister, body: cloudwire.PushRegister{ + APNsToken: []byte("watch-" + kind), Environment: cloudwire.PushSandbox, Kind: kind, App: cloudwire.PushAppWatch}}) + if a.status != http.StatusOK || len(a.payload) != 0 { + t.Fatalf("register %s: %d, %d bytes", kind, a.status, len(a.payload)) + } + regs, _ := h.reg.PushRegs(h.home, kind) + if len(regs) != 1 || regs[0].DeviceID != "watch" || regs[0].App != cloudwire.PushAppWatch { + t.Fatalf("%s registrations %+v", kind, regs) + } + } + a := h.once(onceOpts{key: watch, t: cloudwire.MsgPushUnregister, body: cloudwire.PushUnregister{APNsToken: []byte("watch-alert")}}) + if a.status != http.StatusOK { + t.Fatalf("unregister: %d", a.status) + } + if regs, _ := h.reg.PushRegs(h.home, cloudwire.PushKindAlert); len(regs) != 0 { + t.Fatal("the watch's alert token stayed") + } + // A registration the watch may not make stores nothing and is answered + // as the app session answers it. + a = h.once(onceOpts{key: watch, t: cloudwire.MsgPushRegister, body: cloudwire.PushRegister{ + APNsToken: []byte("odd"), Environment: cloudwire.PushSandbox, Kind: cloudwire.PushKindAlert, App: "tv"}}) + if a.status != http.StatusOK { + t.Fatalf("an odd registration: %d", a.status) + } + if regs, _ := h.reg.PushRegs(h.home, cloudwire.PushKindAlert); len(regs) != 0 { + t.Fatal("an odd app was registered") + } +} + +// Without a token or push service, those messages get 501 and a read still +// works. +func TestOnceWithoutTokensOrPush(t *testing.T) { + h := newHarness(t) + h.live(true) + if a := h.once(onceOpts{key: h.owner, t: cloudwire.MsgTokenGet, body: struct{}{}}); a.status != http.StatusNotImplemented { + t.Fatalf("token.get: %d", a.status) + } + if a := h.once(onceOpts{key: h.owner, t: cloudwire.MsgPushUnregister, body: cloudwire.PushUnregister{}}); a.status != http.StatusNotImplemented { + t.Fatalf("push.unregister: %d", a.status) + } +} + +// The service logs no body, no home, no key and no token, whatever a read +// asks and however it ends. +func TestOnceLogsNoBodies(t *testing.T) { + h := newHarness(t) + var buf lockedBuffer + h.app.log = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + h.app.Push = push.New(h.reg, nil, h.app.log) + _ = h.tokens(t) + watch, _ := appwire.GenerateKeyPair() + h.addWatch(watch, protocol.CloudScopes...) + full := h.live(true) + h.once(onceOpts{key: watch, body: cloudwire.Read{Hz: 1}}) + tokAnswer := h.once(onceOpts{key: watch, t: cloudwire.MsgTokenGet, body: struct{}{}}) + h.once(onceOpts{key: watch, t: cloudwire.MsgPushRegister, body: cloudwire.PushRegister{ + APNsToken: []byte("apns-token-canary"), Environment: cloudwire.PushSandbox, Kind: cloudwire.PushKindAlert, App: cloudwire.PushAppWatch}}) + h.once(onceOpts{key: watch, t: cloudwire.MsgSuspend, body: cloudwire.Suspend{DeviceID: "owner"}}) + stranger, _ := appwire.GenerateKeyPair() + h.once(onceOpts{key: stranger}) + h.once(onceOpts{key: watch, raw: bytes.Repeat([]byte{7}, 200)}) + + env, _ := cloudwire.DecodeEnvelope(tokAnswer.payload) + var tok cloudwire.Token + _ = cloudwire.Unmarshal(env.B, &tok) + logged := buf.String() + for name, secret := range map[string]string{ + "the home": h.home, + "the token": tok.Token, + "the APNs token": "apns-token-canary", + "a reading": string(full[len(full)-20:]), + "the watch key": string(watch.Public()), + } { + if secret != "" && strings.Contains(logged, secret) { + t.Fatalf("the log holds %s:\n%s", name, logged) + } + } + if !strings.Contains(logged, "one-shot read refused") { + t.Fatalf("the log said nothing at debug level, so the test proves nothing:\n%s", logged) + } +} + +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestOnceRefusesPlainHTTP(t *testing.T) { + h := newHarness(t) + req := httptest.NewRequest(http.MethodPost, cloudwire.OncePath+"?home="+h.home, bytes.NewReader([]byte{1})) + req.Header.Set("Content-Type", "application/octet-stream") + w := httptest.NewRecorder() + h.app.ServeOnce(w, req) + if w.Code != http.StatusUpgradeRequired { + t.Fatalf("plain HTTP: %d", w.Code) + } +} diff --git a/internal/appsession/once_vectors_test.go b/internal/appsession/once_vectors_test.go new file mode 100644 index 0000000..2a94c13 --- /dev/null +++ b/internal/appsession/once_vectors_test.go @@ -0,0 +1,157 @@ +package appsession + +import ( + "bytes" + "crypto/tls" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/registry" + "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// protocolTestdata is the testdata directory of the go/protocol this build +// pins, so the vectors move with the pin. +func protocolTestdata(t *testing.T) string { + t.Helper() + out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/srcfl/ftw/go/protocol").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + return filepath.Join(strings.TrimSpace(string(out)), "cloudwire", "testdata") +} + +func readVectors(t *testing.T, name string, into any) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(protocolTestdata(t), name)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, into); err != nil { + t.Fatal(err) + } +} + +func unhex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} + +// The service answers srcfl/ftw's cloud-once-vectors.json byte for byte: the +// same request, with the service's keys fixed as the vectors fix them and +// the home holding the envelopes of cloud-vectors.json, draws exactly the +// answer body the watch replays. +func TestOnceAnswersTheGoldenVectors(t *testing.T) { + var once struct { + ServiceStatic, ServiceEphemeral, WatchPublic string + Home, Path, Request, Msg2Payload, Answer string + Held []string + } + readVectors(t, "cloud-once-vectors.json", &once) + var signed struct { + Messages []struct { + Name, T, Envelope string + } `json:"messages"` + } + readVectors(t, "cloud-vectors.json", &signed) + envelope := func(name string) []byte { + for _, m := range signed.Messages { + if m.Name == name { + return unhex(t, m.Envelope) + } + } + t.Fatalf("no signed vector %q", name) + return nil + } + + key, err := appwire.KeyPairFromSecret(unhex(t, once.ServiceStatic)) + if err != nil { + t.Fatal(err) + } + eph, err := appwire.KeyPairFromSecret(unhex(t, once.ServiceEphemeral)) + if err != nil { + t.Fatal(err) + } + // The service last heard from the box when message 2 says it did. + var msg2 struct { + LastSeenMs int64 `cbor:"lastSeenMs"` + } + env, err := cloudwire.DecodeEnvelope(unhex(t, once.Msg2Payload)) + if err != nil || cloudwire.Unmarshal(env.B, &msg2) != nil { + t.Fatalf("message 2 payload: %v", err) + } + st := store.New(func() time.Time { return time.UnixMilli(msg2.LastSeenMs) }) + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reg.Close() }) + box, _ := st.For(store.Principal{Kind: store.Box, Home: once.Home}) + box.Connected() + box.SetRoster(&cloudwire.Roster{RosterSeq: 1, TTLS: 86_400, + Devices: []cloudwire.Device{{ID: "watch", Key: unhex(t, once.WatchPublic), Role: "watch", + Scopes: []string{protocol.ScopeLiveRead, protocol.ScopePlanRead, protocol.ScopeEventsRead}, Epoch: 1}}, + Grant: cloudwire.Grant{ID: make([]byte, 16), Scopes: []string{protocol.ScopeLiveRead}, + Purposes: []string{protocol.CloudPurposeServe}, Controller: cloudwire.ControllerSourceful}}) + for _, n := range []string{"live full", "live delta", "lp", "prices", "hb"} { + raw := envelope(n) + e, _ := cloudwire.DecodeEnvelope(raw) + var hdr cloudwire.Header + if err := cloudwire.Unmarshal(e.B, &hdr); err != nil { + t.Fatal(err) + } + if err := box.Put(e.T, raw, hdr.Seq, n == "live full"); err != nil { + t.Fatal(err) + } + } + + w := &watchCount{} + app := New(st, reg, w, key, slog.New(slog.NewTextHandler(io.Discard, nil))) + app.onceEphemeral = &eph + // The vectors carry two envelopes in message 2 and one after it, as an + // envelope that did not fit would follow. Bound message 2 the same way. + two, _ := cloudwire.SnapshotPayload([][]byte{envelope("live delta"), envelope("prices")}, msg2.LastSeenMs, true, 1) + app.onceMessage2 = len(two) + appwire.Message2Overhead + srv := httptest.NewUnstartedServer(http.HandlerFunc(app.ServeOnce)) + srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} + srv.StartTLS() + t.Cleanup(srv.Close) + + req, _ := http.NewRequest(http.MethodPost, srv.URL+once.Path, bytes.NewReader(unhex(t, once.Request))) + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("the vectors' request drew %d", resp.StatusCode) + } + if !bytes.Equal(body, unhex(t, once.Answer)) { + t.Fatalf("the answer is not the vectors' bytes:\n got %x\nwant %s", body, once.Answer) + } + if w.count() != 1 { + t.Fatalf("hz 1 asked the box %d times", w.count()) + } + if len(once.Held) != 2 || once.Held[0] != "live full" || once.Held[1] != "lp" { + t.Fatalf("the vectors' have covers %v; this test assumes the full live and the lp", once.Held) + } +} diff --git a/internal/appsession/perf_test.go b/internal/appsession/perf_test.go index e203758..ceb7d77 100644 --- a/internal/appsession/perf_test.go +++ b/internal/appsession/perf_test.go @@ -96,7 +96,7 @@ func BenchmarkSnapshot(b *testing.B) { if err != nil { b.Fatal(err) } - payload, _, err := snapshotPayload(snap) + payload, _, err := snapshotPayload(snap, maxNoiseMessage) if err != nil { b.Fatal(err) } diff --git a/internal/appsession/token_test.go b/internal/appsession/token_test.go index 817e90e..11991fd 100644 --- a/internal/appsession/token_test.go +++ b/internal/appsession/token_test.go @@ -14,8 +14,8 @@ import ( ) // tokens gives the harness a token service and /v1/now behind TLS, and -// returns a reader that answers with the status a token gets there. -func (h *harness) tokens(t *testing.T) func(tok string) int { +// returns a reader that answers with what a token gets there. +func (h *harness) tokens(t *testing.T) func(tok string) (int, []byte) { t.Helper() svc := token.New(h.store, h.reg, nil) h.app.Tokens = svc @@ -23,16 +23,16 @@ func (h *harness) tokens(t *testing.T) func(tok string) int { srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} srv.StartTLS() t.Cleanup(srv.Close) - return func(tok string) int { + return func(tok string) (int, []byte) { req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/now", nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } - _, _ = io.Copy(io.Discard, resp.Body) - resp.Body.Close() - return resp.StatusCode + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, body } } @@ -75,14 +75,14 @@ func TestADeviceGetsItsTokenOverItsSession(t *testing.T) { if first.ExpiresInS != int64(token.Life/time.Second) { t.Fatalf("expires in %d s", first.ExpiresInS) } - if code := read(first.Token); code != http.StatusOK { + if code, _ := read(first.Token); code != http.StatusOK { t.Fatalf("the token read %d", code) } second := viewer.getToken() - if code := read(first.Token); code != http.StatusUnauthorized { + if code, _ := read(first.Token); code != http.StatusUnauthorized { t.Fatalf("the replaced token read %d", code) } - if code := read(second.Token); code != http.StatusOK { + if code, _ := read(second.Token); code != http.StatusOK { t.Fatalf("the new token read %d", code) } next := h.live(false) @@ -104,12 +104,12 @@ func TestASuspensionKillsTheToken(t *testing.T) { if code := viewer.closed(2 * time.Second); code != cloudwire.CloseDenied { t.Fatalf("the suspended viewer closed with %d", code) } - if code := read(tok.Token); code != http.StatusUnauthorized { + if code, _ := read(tok.Token); code != http.StatusUnauthorized { t.Fatalf("a suspended device's token read %d", code) } owner.send(cloudwire.MsgUnsuspend, cloudwire.Suspend{DeviceID: "viewer"}) waitFor(t, "the suspension to lift", func() bool { return !h.reg.Suspended(h.home, "viewer", 1) }) - if code := read(tok.Token); code != http.StatusUnauthorized { + if code, _ := read(tok.Token); code != http.StatusUnauthorized { t.Fatalf("lifting the suspension brought the token back: %d", code) } } diff --git a/internal/push/push.go b/internal/push/push.go index 57a50aa..5e5f3b2 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -22,6 +22,10 @@ // its epoch or narrows its scope drops its tokens. A device an owner has // suspended gets nothing until the suspension ends. Nothing here logs a // token or a home id. +// +// A watch registers its own tokens with app watch (ADR 0009). Its pushes go +// to the watch app's topic, and an alert carries the same payload, byte for +// byte, as its phone's, so Apple shows one when both are near. package push import ( @@ -97,6 +101,11 @@ type Service struct { sender Sender log *slog.Logger now func() time.Time + + // WatchApp is the watch app's bundle id, the APNs topic for tokens + // registered with app watch. Empty sends them nothing. The iPhone app's + // is the sender's own. Set before serving. + WatchApp string // after runs f after d and returns a function that stops it. Tests // replace it. after func(d time.Duration, f func()) (stop func() bool) @@ -177,13 +186,18 @@ func (s *Service) enqueue(job func(context.Context)) { // Noise key matched and scopes its effective scope, both from store.For. func (s *Service) Register(home string, dev cloudwire.Device, scopes []string, r cloudwire.PushRegister) error { k, known := kinds[r.Kind] + app := r.App + if app == "" { + app = cloudwire.PushAppIPhone + } if !known || !slices.Contains(scopes, k.scope) || + (app != cloudwire.PushAppIPhone && app != cloudwire.PushAppWatch) || len(r.APNsToken) == 0 || len(r.APNsToken) > cloudwire.APNsTokenMaxBytes || (r.Environment != cloudwire.PushSandbox && r.Environment != cloudwire.PushProduction) { return ErrNotForThisDevice } return s.reg.PushRegister(registry.PushReg{Home: home, DeviceID: dev.ID, Epoch: dev.Epoch, - Token: r.APNsToken, Environment: r.Environment, Kind: r.Kind, At: s.now()}) + Token: r.APNsToken, Environment: r.Environment, Kind: r.Kind, App: app, At: s.now()}) } // Unregister forgets one of the device's tokens. @@ -392,9 +406,10 @@ func (s *Service) send(ctx context.Context, home, kindName string, payload []byt return } // A device an owner has suspended keeps its tokens, so lifting the - // suspension brings its alerts back, but gets nothing meanwhile. + // suspension brings its alerts back, but gets nothing meanwhile. A + // watch's token waits for the watch app's topic. regs = slices.DeleteFunc(regs, func(r registry.PushReg) bool { - return s.reg.Suspended(home, r.DeviceID, r.Epoch) + return s.reg.Suspended(home, r.DeviceID, r.Epoch) || (r.App == cloudwire.PushAppWatch && s.WatchApp == "") }) if len(regs) == 0 { return @@ -407,8 +422,14 @@ func (s *Service) send(ctx context.Context, home, kindName string, payload []byt var sent, gone, failed int exp := s.now().Add(expiry) for _, r := range regs { + // A watch and its phone get the same payload, so Apple shows one + // alert when both are near; only the topic differs. + app := "" + if r.App == cloudwire.PushAppWatch { + app = s.WatchApp + } res, err := s.sender.Send(ctx, apns.Notification{Environment: r.Environment, DeviceToken: r.Token, - Type: k.pushType, Payload: payload, Expiration: exp}) + App: app, Type: k.pushType, Payload: payload, Expiration: exp}) switch { case err != nil: failed++ diff --git a/internal/push/widgets_test.go b/internal/push/widgets_test.go index aa81003..ab820e1 100644 --- a/internal/push/widgets_test.go +++ b/internal/push/widgets_test.go @@ -226,3 +226,74 @@ func TestASuspendedDevicesWidgetsGetNothing(t *testing.T) { t.Fatalf("sent to %+v", got) } } + +// ADR 0009: a watch and its phone get byte-identical alert payloads, so +// Apple shows one alert when both are near; only the topic differs. A +// watch's widgets go to the watch app's widgets topic. +func TestAWatchAndItsPhoneGetTheSameAlert(t *testing.T) { + r := newRig(t) + r.svc.WatchApp = "energy.ftw.app.watchkitapp" + watch := cloudwire.Device{ID: "watch", Key: bytes.Repeat([]byte{8}, 32), Role: "watch", Scopes: r.all, Epoch: 1} + _ = r.register(t, r.owner, r.all, "phone-alerts") + if err := r.svc.Register(r.home, watch, r.all, cloudwire.PushRegister{APNsToken: []byte("watch-alerts"), + Environment: cloudwire.PushSandbox, Kind: cloudwire.PushKindAlert, App: cloudwire.PushAppWatch}); err != nil { + t.Fatal(err) + } + if err := r.svc.Register(r.home, watch, r.all, cloudwire.PushRegister{APNsToken: []byte("watch-widgets"), + Environment: cloudwire.PushSandbox, Kind: KindWidgets, App: cloudwire.PushAppWatch}); err != nil { + t.Fatal(err) + } + r.svc.Event(r.home, []byte{0xa1, 0x61, 0x74, 0x65, 'e', 'v', 'e', 'n', 't'}) + got := r.taken() + if len(got) != 2 { + t.Fatalf("the alert went to %d devices", len(got)) + } + byToken := map[string]apns.Notification{} + for _, n := range got { + byToken[string(n.DeviceToken)] = n + } + phone, watchN := byToken["phone-alerts"], byToken["watch-alerts"] + if !bytes.Equal(phone.Payload, watchN.Payload) || len(phone.Payload) == 0 { + t.Fatal("the watch and its phone got different payloads") + } + if phone.App != "" || watchN.App != "energy.ftw.app.watchkitapp" || phone.Type != watchN.Type { + t.Fatalf("phone to %q, watch to %q", phone.App, watchN.App) + } + + box := newBoxSigner(t) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + got = r.taken() + if len(got) != 1 || got[0].App != "energy.ftw.app.watchkitapp" || got[0].Type != apns.PushWidgets { + t.Fatalf("the watch's widgets push %+v", got) + } + + // A service that names no watch app sends a watch nothing, and its + // phone everything. + r.svc.WatchApp = "" + r.svc.Event(r.home, []byte{0xa0}) + if got := r.taken(); len(got) != 1 || string(got[0].DeviceToken) != "phone-alerts" { + t.Fatalf("without a watch app, sent %+v", got) + } +} + +// app names the app a token belongs to; anything but iphone or watch is +// refused, and none is the iPhone app. +func TestPushRegisterTakesOnlyKnownApps(t *testing.T) { + r := newRig(t) + for app, ok := range map[string]bool{"": true, cloudwire.PushAppIPhone: true, cloudwire.PushAppWatch: true, "ipad": false} { + err := r.svc.Register(r.home, r.owner, r.all, cloudwire.PushRegister{APNsToken: []byte("t-" + app), + Environment: cloudwire.PushSandbox, Kind: cloudwire.PushKindAlert, App: app}) + if (err == nil) != ok { + t.Errorf("app %q: %v", app, err) + } + } + regs, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert) + apps := map[string]string{} + for _, reg := range regs { + apps[string(reg.Token)] = reg.App + } + if apps["t-"] != cloudwire.PushAppIPhone || apps["t-iphone"] != cloudwire.PushAppIPhone || apps["t-watch"] != cloudwire.PushAppWatch { + t.Fatalf("stored apps %v", apps) + } +} diff --git a/internal/registry/push.go b/internal/registry/push.go index decc546..2f7f2e8 100644 --- a/internal/registry/push.go +++ b/internal/registry/push.go @@ -3,6 +3,8 @@ package registry import ( "database/sql" "time" + + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // APNs registrations. Each row binds one device token to one roster entry @@ -23,11 +25,14 @@ type PushReg struct { Token []byte Environment string Kind string - At time.Time + // App is the app the token belongs to, cloudwire.PushAppIPhone or + // cloudwire.PushAppWatch; it picks the APNs topic. + App string + At time.Time } func createPushTable(db *sql.DB) error { - _, err := db.Exec(` + if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS push ( home TEXT NOT NULL REFERENCES homes(home) ON DELETE CASCADE, device_id TEXT NOT NULL, @@ -37,18 +42,35 @@ CREATE TABLE IF NOT EXISTS push ( kind TEXT NOT NULL, registered_ms INTEGER NOT NULL, PRIMARY KEY (home, token, kind) -);`) - return err +);`); err != nil { + return err + } + // A registry from before watches has no app column: its tokens are the + // iPhone app's. + var has int + if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('push') WHERE name = 'app'`).Scan(&has); err != nil { + return err + } + if has == 0 { + if _, err := db.Exec(`ALTER TABLE push ADD COLUMN app TEXT NOT NULL DEFAULT 'iphone'`); err != nil { + return err + } + } + return nil } // PushRegister keeps a device's token. A token already registered for the // home moves to this device: a phone that paired again keeps its token. func (r *Registry) PushRegister(reg PushReg) error { - _, err := r.db.Exec(`INSERT INTO push (home, device_id, epoch, token, environment, kind, registered_ms) - VALUES (?, ?, ?, ?, ?, ?, ?) + app := reg.App + if app == "" { + app = cloudwire.PushAppIPhone + } + _, err := r.db.Exec(`INSERT INTO push (home, device_id, epoch, token, environment, kind, app, registered_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (home, token, kind) DO UPDATE SET device_id = excluded.device_id, epoch = excluded.epoch, - environment = excluded.environment, registered_ms = excluded.registered_ms`, - reg.Home, reg.DeviceID, int64(reg.Epoch), reg.Token, reg.Environment, reg.Kind, reg.At.UnixMilli()) + environment = excluded.environment, app = excluded.app, registered_ms = excluded.registered_ms`, + reg.Home, reg.DeviceID, int64(reg.Epoch), reg.Token, reg.Environment, reg.Kind, app, reg.At.UnixMilli()) return err } @@ -60,7 +82,7 @@ func (r *Registry) PushUnregister(home, deviceID string, token []byte) error { // PushRegs returns a home's registrations of one kind. func (r *Registry) PushRegs(home, kind string) ([]PushReg, error) { - rows, err := r.db.Query(`SELECT device_id, epoch, token, environment, registered_ms FROM push + rows, err := r.db.Query(`SELECT device_id, epoch, token, environment, app, registered_ms FROM push WHERE home = ? AND kind = ? ORDER BY registered_ms`, home, kind) if err != nil { return nil, err @@ -70,7 +92,7 @@ func (r *Registry) PushRegs(home, kind string) ([]PushReg, error) { for rows.Next() { reg := PushReg{Home: home, Kind: kind} var epoch, ms int64 - if err := rows.Scan(®.DeviceID, &epoch, ®.Token, ®.Environment, &ms); err != nil { + if err := rows.Scan(®.DeviceID, &epoch, ®.Token, ®.Environment, ®.App, &ms); err != nil { return nil, err } reg.Epoch, reg.At = uint64(epoch), time.UnixMilli(ms) diff --git a/internal/registry/push_test.go b/internal/registry/push_test.go index ec6ea14..28617e6 100644 --- a/internal/registry/push_test.go +++ b/internal/registry/push_test.go @@ -1,6 +1,8 @@ package registry import ( + "database/sql" + "path/filepath" "testing" "time" ) @@ -109,3 +111,41 @@ func TestARegistrationNeedsAHome(t *testing.T) { t.Fatal("registered a token for a home that does not exist") } } + +// A registry from before watches gains the app column, and its tokens stay +// the iPhone app's. +func TestARegistryFromBeforeWatchesKeepsItsTokens(t *testing.T) { + path := filepath.Join(t.TempDir(), "registry.db") + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + for _, q := range []string{ + `CREATE TABLE homes (home TEXT PRIMARY KEY, pk BLOB NOT NULL, created_ms INTEGER NOT NULL, roster BLOB, + roster_seq INTEGER NOT NULL DEFAULT 0, roster_ms INTEGER NOT NULL DEFAULT 0, grant_id BLOB)`, + `CREATE TABLE push (home TEXT NOT NULL REFERENCES homes(home) ON DELETE CASCADE, device_id TEXT NOT NULL, + epoch INTEGER NOT NULL, token BLOB NOT NULL, environment TEXT NOT NULL, kind TEXT NOT NULL, + registered_ms INTEGER NOT NULL, PRIMARY KEY (home, token, kind))`, + `INSERT INTO homes (home, pk, created_ms) VALUES ('h1', x'00', 1)`, + `INSERT INTO push VALUES ('h1', 'phone', 1, x'aa', 'sandbox', 'alert', 1)`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatal(err) + } + } + _ = db.Close() + r, err := Open(path) + if err != nil { + t.Fatal(err) + } + regs, err := r.PushRegs("h1", "alert") + if err != nil || len(regs) != 1 || regs[0].App != "iphone" { + t.Fatalf("after the upgrade: %+v %v", regs, err) + } + _ = r.Close() + again, err := Open(path) + if err != nil { + t.Fatalf("a second open: %v", err) + } + _ = again.Close() +} diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go new file mode 100644 index 0000000..965ee84 --- /dev/null +++ b/internal/server/e2e_test.go @@ -0,0 +1,546 @@ +package server + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/srcfl/ftw-cloud/internal/apns" + "github.com/srcfl/ftw-cloud/internal/appsession" + "github.com/srcfl/ftw-cloud/internal/push" + "github.com/srcfl/ftw-cloud/internal/registry" + "github.com/srcfl/ftw-cloud/internal/store" + "github.com/srcfl/ftw-cloud/internal/token" + "github.com/srcfl/ftw-cloud/internal/uplink" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/appwire" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// The whole service, wired as ftwcloudd wires it, behind TLS 1.3, with a +// box on its uplink and a watch that reads it the way watchOS allows: one +// HTTPS POST at a time. + +type capturedApple struct { + mu sync.Mutex + sent []apns.Notification +} + +func (a *capturedApple) Send(_ context.Context, n apns.Notification) (apns.Result, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.sent = append(a.sent, n) + return apns.Result{Status: http.StatusOK}, nil +} + +func (a *capturedApple) taken() []apns.Notification { + a.mu.Lock() + defer a.mu.Unlock() + out := a.sent + a.sent = nil + return out +} + +type stack struct { + t *testing.T + srv *httptest.Server + st *store.Store + serviceKey *ecdsa.PrivateKey + noise appwire.KeyPair + apple *capturedApple +} + +func newStack(t *testing.T) *stack { + t.Helper() + serviceKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + noise, _ := appwire.GenerateKeyPair() + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reg.Close() }) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + st := store.New(nil) + st.UseSuspensions(reg.Suspended) + up := uplink.New(st, reg, serviceKey, log) + apple := &capturedApple{} + pushSvc := push.New(reg, apple, log) + pushSvc.WatchApp = "energy.ftw.app.watchkitapp" + tokens := token.New(st, reg, log) + app := appsession.New(st, reg, up, noise, log) + app.Push, app.Tokens = pushSvc, tokens + up.OnHeld = pushSvc.Held + up.OnRoster = func(home string, r *cloudwire.Roster) { + pushSvc.Roster(home, r) + tokens.Roster(home, r) + } + up.OnGone = pushSvc.BoxGone + up.OnConnect = func(home string) { + app.BoxConnected(home) + pushSvc.BoxConnected(home) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go pushSvc.Run(ctx) + + s := &Server{Store: st, Registry: reg, Uplink: up, App: app, Tokens: tokens, Log: log} + srv := httptest.NewUnstartedServer(s.Handler()) + srv.TLS = &tls.Config{MinVersion: tls.VersionTLS13} + srv.StartTLS() + t.Cleanup(srv.Close) + return &stack{t: t, srv: srv, st: st, serviceKey: serviceKey, noise: noise, apple: apple} +} + +// testBox is a box's half of the uplink. +type testBox struct { + t *testing.T + key *ecdsa.PrivateKey + home string + boot []byte + seq uint64 + // liveSeq is the seq of the newest live sent, which hb names. + liveSeq uint64 + conn *websocket.Conn + // heard is what the service told the box after hello_ok. + heard chan string + start time.Time +} + +func (s *stack) connectBox() *testBox { + s.t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + pk, _ := cloudwire.PublicKeyBytesOf(&key.PublicKey) + b := &testBox{t: s.t, key: key, home: cloudwire.HomeID(pk), boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes), + heard: make(chan string, 16), start: time.Now()} + pool := x509.NewCertPool() + pool.AddCert(s.srv.Certificate()) + d := websocket.Dialer{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS13}} + conn, _, err := d.Dial(strings.Replace(s.srv.URL, "https://", "wss://", 1)+"/v1/box", nil) + if err != nil { + s.t.Fatal(err) + } + s.t.Cleanup(func() { _ = conn.Close() }) + b.conn = conn + cs := conn.NetConn().(*tls.Conn).ConnectionState() + ekm, _ := cs.ExportKeyingMaterial(cloudwire.EKMLabel, nil, cloudwire.EKMBytes) + env := b.read() + var ch cloudwire.Challenge + if env.T != cloudwire.MsgChallenge || cloudwire.Unmarshal(env.B, &ch) != nil { + s.t.Fatalf("first message %q", env.T) + } + boxNonce := bytes.Repeat([]byte{4}, cloudwire.NonceBytes) + b.send(cloudwire.MsgHello, cloudwire.Hello{Header: b.header(), V: cloudwire.Version, PK: pk, Nonce: ch.Nonce, EKM: ekm, + BoxNonce: boxNonce, Build: "e2e", Clock: cloudwire.ClockNTP, RosterSeq: 1, GrantID: bytes.Repeat([]byte{5}, 16)}) + env = b.read() + if env.T != cloudwire.MsgHelloOK || cloudwire.Verify(&s.serviceKey.PublicKey, b.home, env) != nil { + s.t.Fatal("hello_ok did not check out") + } + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + close(b.heard) + return + } + payload, _ := cloudwire.DecodeFrame(msg) + e, _ := cloudwire.DecodeEnvelope(payload) + b.heard <- e.T + } + }() + return b +} + +func (b *testBox) read() cloudwire.Envelope { + b.t.Helper() + _ = b.conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := b.conn.ReadMessage() + if err != nil { + b.t.Fatal(err) + } + payload, err := cloudwire.DecodeFrame(msg) + if err != nil { + b.t.Fatal(err) + } + env, err := cloudwire.DecodeEnvelope(payload) + if err != nil { + b.t.Fatal(err) + } + return env +} + +func (b *testBox) header() cloudwire.Header { + b.seq++ + wall := time.Now().UnixMilli() + return cloudwire.Header{Seq: b.seq, Boot: b.boot, UptimeMs: time.Since(b.start).Milliseconds(), WallMs: &wall} +} + +func (b *testBox) send(t string, body any) []byte { + b.t.Helper() + env, err := cloudwire.Seal(b.key, b.home, t, body) + if err != nil { + b.t.Fatal(err) + } + raw, _ := env.Encode() + frame, _ := cloudwire.EncodeFrame(raw) + if err := b.conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + b.t.Fatal(err) + } + return raw +} + +func (b *testBox) roster(seq uint64, devices ...cloudwire.Device) { + b.send(cloudwire.MsgRoster, cloudwire.Roster{Header: b.header(), RosterSeq: seq, TTLS: 86_400, Devices: devices, + Grant: cloudwire.Grant{ID: bytes.Repeat([]byte{5}, 16), Scopes: protocol.CloudScopes, + Purposes: []string{protocol.CloudPurposeServe}, Controller: cloudwire.ControllerSourceful}}) +} + +func (b *testBox) live(full bool) []byte { + l := cloudwire.Live{Header: b.header(), Fields: map[string]int64{"2": 1800}, SrcRev: 1, Ages: map[string]int64{"m": 300}} + b.liveSeq = l.Seq + if full { + l.Sources = map[string]cloudwire.Source{"m": {Kind: "driver", State: protocol.SourceLive, StaleAfterMs: 10_000}} + l.FieldSources = map[string][]string{"2": {"m"}} + } + return b.send(cloudwire.MsgLive, l) +} + +func (b *testBox) hb() []byte { + return b.send(cloudwire.MsgHB, cloudwire.HB{Header: b.header(), Clock: cloudwire.ClockNTP, SrcRev: 1, + LiveSeq: b.liveSeq, Ages: map[string]int64{"m": 300}}) +} + +// heardWatch reports whether the service asked the box for one-second +// updates within a second. +func (b *testBox) heardWatch() bool { + deadline := time.After(time.Second) + for { + select { + case t, ok := <-b.heard: + if !ok { + return false + } + if t == cloudwire.MsgWatch { + return true + } + case <-deadline: + return false + } + } +} + +// watchClient is the watch's side of the one-shot read: Noise IK message 1 +// in one POST, message 2 and what follows in the answer. It is the +// reference the watch's Swift port follows. +type watchClient struct { + t *testing.T + http *http.Client + base string + key appwire.KeyPair + service []byte + home string +} + +// call posts one message 1 and returns the status, message 2's payload and +// the envelopes that followed it. +func (w *watchClient) call(t string, body any) (int, []byte, [][]byte) { + w.t.Helper() + init, err := appwire.NewInitiator(appwire.InitiatorOptions{StaticKey: w.key, RemoteStatic: w.service, + Prologue: cloudwire.OncePrologue(w.home)}) + if err != nil { + w.t.Fatal(err) + } + payload, _ := cloudwire.Unsigned(t, body) + msg1, err := init.WriteMessage(payload) + if err != nil { + w.t.Fatal(err) + } + req, _ := http.NewRequest(http.MethodPost, w.base+cloudwire.OncePath+"?"+cloudwire.AppHomeParam+"="+w.home, bytes.NewReader(msg1)) + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := w.http.Do(req) + if err != nil { + w.t.Fatal(err) + } + defer resp.Body.Close() + answer, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, nil, nil + } + parts, err := cloudwire.SplitOnceAnswer(answer) + if err != nil { + w.t.Fatal(err) + } + reply, err := init.ReadMessage(parts[0]) + if err != nil { + w.t.Fatal(err) + } + keys, _ := init.Split() + tr := appwire.NewTransport(keys) + var following [][]byte + for _, m := range parts[1:] { + frame, err := tr.Decrypt(m) + if err != nil { + w.t.Fatal(err) + } + env, err := cloudwire.DecodeFrame(frame) + if err != nil { + w.t.Fatal(err) + } + following = append(following, env) + } + return resp.StatusCode, reply, following +} + +// read asks for the snapshot less what the watch holds, and feeds every +// envelope through the freshness rules with the box key the watch learned +// from the box, never from the service. +func (w *watchClient) read(view *cloudwire.View, have cloudwire.Have, hz int) (int, cloudwire.Snapshot, [][]byte) { + w.t.Helper() + status, reply, following := w.call(cloudwire.MsgRead, cloudwire.Read{Have: have, Hz: hz}) + if status != http.StatusOK { + return status, cloudwire.Snapshot{}, nil + } + snap, err := cloudwire.ParseSnapshot(reply) + if err != nil || snap.More != len(following) { + w.t.Fatalf("snapshot %+v, %d followed: %v", snap, len(following), err) + } + var all [][]byte + for _, e := range snap.Envelopes { + all = append(all, e) + } + all = append(all, following...) + arrival := time.Now().UnixMilli() + for _, raw := range all { + env, err := cloudwire.DecodeEnvelope(raw) + if err != nil { + w.t.Fatal(err) + } + if err := view.Accept(env, arrival); err != nil { + w.t.Fatalf("the watch dropped a %s: %v", env.T, err) + } + } + return status, snap, all +} + +// A box with FTW Cloud on, a watch in its roster, and the watch reading, +// asking for its token, registering for alerts and being removed, all over +// real TLS with the service wired as ftwcloudd wires it. +func TestAWatchReadsItsHomeEndToEnd(t *testing.T) { + s := newStack(t) + box := s.connectBox() + phoneKey, _ := appwire.GenerateKeyPair() + watchKey, _ := appwire.GenerateKeyPair() + phone := cloudwire.Device{ID: "phone", Key: phoneKey.Public(), Role: "owner", Scopes: protocol.CloudScopes, Epoch: 1} + watchDev := cloudwire.Device{ID: "watch", Key: watchKey.Public(), Role: "watch", + Scopes: []string{protocol.ScopeLiveRead, protocol.ScopePlanRead, protocol.ScopeEventsRead}, Epoch: 1} + box.roster(1, phone, watchDev) + full := box.live(true) + prices := box.send(cloudwire.MsgPrices, cloudwire.Prices{Header: box.header(), Zone: "SE3", Currency: "SEK", + SpotMinor: []*int64{}, TotalMinor: []*int64{}}) + lp := box.send(cloudwire.MsgLP, cloudwire.LP{Header: box.header(), Loadpoints: []cloudwire.Loadpoint{}, Feedback: []cloudwire.Feedback{}}) + hb := box.hb() + waitHeld(t, s.st, box.home, phoneKey, 4) + + boxPub := &box.key.PublicKey + watch := &watchClient{t: t, http: s.srv.Client(), base: s.srv.URL, key: watchKey, service: s.noise.Public(), home: box.home} + view := cloudwire.NewView(box.home, boxPub) + + // The first read: everything the watch may read, the box's bytes, live. + status, snap, all := watch.read(view, cloudwire.Have{}, cloudwire.HzLooking) + if status != http.StatusOK || !snap.Online || len(all) != 4 { + t.Fatalf("first read: %d, online %v, %d envelopes", status, snap.Online, len(all)) + } + for i, want := range [][]byte{full, prices, lp, hb} { + if !bytes.Equal(all[i], want) { + t.Fatalf("envelope %d is not the box's bytes", i) + } + } + g := view.Grade(time.Now().UnixMilli()) + if !g.Anchored || g.Sources["m"] != protocol.SourceLive || g.Fields["2"] != protocol.SourceLive { + t.Fatalf("the first read grades %+v", g) + } + if !box.heardWatch() { + t.Fatal("hz 1 did not ask the box for one-second updates") + } + + // The second read carries only what is new. + delta := box.live(false) + waitHeld(t, s.st, box.home, phoneKey, 5) + if _, _, all = watch.read(view, haveOf(t, all), cloudwire.HzAway); len(all) != 1 || !bytes.Equal(all[0], delta) { + t.Fatalf("the second read carried %d envelopes", len(all)) + } + + // The watch's own token reads live, prices and hb. + status, reply, _ := watch.call(cloudwire.MsgTokenGet, struct{}{}) + env, _ := cloudwire.DecodeEnvelope(reply) + var tok cloudwire.Token + if status != http.StatusOK || env.T != cloudwire.MsgToken || cloudwire.Unmarshal(env.B, &tok) != nil { + t.Fatalf("token.get: %d %q", status, env.T) + } + code, now := s.now(tok.Token) + if code != http.StatusOK || len(now.Envelopes) != 4 { + t.Fatalf("the watch's token read %d, %d envelopes", code, len(now.Envelopes)) + } + for _, e := range now.Envelopes { + if bytes.Equal(e, lp) { + t.Fatal("a token read lp") + } + } + + // The watch and its phone register for alerts; an event reaches both + // with the same payload, the watch on its own topic. + phoneClient := *watch + phoneClient.key = phoneKey + for _, c := range []struct { + w *watchClient + app string + }{{watch, cloudwire.PushAppWatch}, {&phoneClient, cloudwire.PushAppIPhone}} { + if status, _, _ := c.w.call(cloudwire.MsgPushRegister, cloudwire.PushRegister{APNsToken: []byte(c.app), + Environment: cloudwire.PushSandbox, Kind: cloudwire.PushKindAlert, App: c.app}); status != http.StatusOK { + t.Fatalf("%s push.register: %d", c.app, status) + } + } + box.send(cloudwire.MsgEvent, cloudwire.Event{Header: box.header(), EventID: "e1", Kind: protocol.PushChargingInterrupted, + Severity: cloudwire.SeverityWarning, Args: map[string]string{}}) + var alerts []apns.Notification + waitFor(t, "two alerts", func() bool { alerts = append(alerts, s.apple.taken()...); return len(alerts) == 2 }) + if !bytes.Equal(alerts[0].Payload, alerts[1].Payload) { + t.Fatal("the watch and its phone got different alerts") + } + topics := map[string]string{string(alerts[0].DeviceToken): alerts[0].App, string(alerts[1].DeviceToken): alerts[1].App} + if topics[cloudwire.PushAppWatch] != "energy.ftw.app.watchkitapp" || topics[cloudwire.PushAppIPhone] != "" { + t.Fatalf("alert topics %v", topics) + } + + // The box removes the watch: within a second its reads, its token and + // its alerts are gone, and the phone reads on. + removed := time.Now() + box.roster(2, phone) + waitFor(t, "the watch refused", func() bool { + status, _, _ := watch.call(cloudwire.MsgRead, cloudwire.Read{}) + return status == http.StatusForbidden + }) + if took := time.Since(removed); took > time.Second { + t.Fatalf("the watch was refused %v after the roster", took) + } + if code, _ := s.now(tok.Token); code != http.StatusUnauthorized { + t.Fatalf("the removed watch's token read %d", code) + } + box.send(cloudwire.MsgEvent, cloudwire.Event{Header: box.header(), EventID: "e2", Kind: protocol.PushChargingInterrupted, + Severity: cloudwire.SeverityWarning, Args: map[string]string{}}) + alerts = nil + waitFor(t, "the phone's alert", func() bool { alerts = append(alerts, s.apple.taken()...); return len(alerts) >= 1 }) + if len(alerts) != 1 || string(alerts[0].DeviceToken) != cloudwire.PushAppIPhone { + t.Fatalf("after removal, alerts went to %d devices", len(alerts)) + } +} + +// The service cannot make data look fresher than the box made it: when the +// box goes quiet, a read says so, and the watch's freshness rules age what +// it holds until it is down. +func TestAOnceReadNeverLooksFresherThanTheBox(t *testing.T) { + s := newStack(t) + box := s.connectBox() + watchKey, _ := appwire.GenerateKeyPair() + box.roster(1, cloudwire.Device{ID: "watch", Key: watchKey.Public(), Role: "watch", + Scopes: []string{protocol.ScopeLiveRead}, Epoch: 1}) + box.live(true) + box.hb() + waitHeld(t, s.st, box.home, watchKey, 2) + _ = box.conn.Close() + waitFor(t, "the box to go", func() bool { + x, err := s.st.For(store.Principal{Kind: store.Device, Home: box.home, DeviceKey: watchKey.Public()}) + if err != nil { + return false + } + snap, _ := x.Snapshot() + return !snap.Online + }) + + watch := &watchClient{t: t, http: s.srv.Client(), base: s.srv.URL, key: watchKey, service: s.noise.Public(), home: box.home} + view := cloudwire.NewView(box.home, &box.key.PublicKey) + status, snap, all := watch.read(view, cloudwire.Have{}, cloudwire.HzLooking) + if status != http.StatusOK || snap.Online || len(all) != 2 { + t.Fatalf("after the box went: %d, online %v, %d envelopes", status, snap.Online, len(all)) + } + nowMs := time.Now().UnixMilli() + if g := view.Grade(nowMs); g.Sources["m"] != protocol.SourceLive { + t.Fatalf("just after: %s", g.Sources["m"]) + } + if g := view.Grade(nowMs + 70_000); g.Sources["m"] != protocol.SourceDown || g.Fields["2"] == protocol.SourceLive { + t.Fatalf("70 s later the source is %s and the field %s", g.Sources["m"], g.Fields["2"]) + } +} + +// haveOf is what a watch holds after accepting envelopes: their boot and +// the highest seq per class. +func haveOf(t *testing.T, envelopes [][]byte) cloudwire.Have { + t.Helper() + have := cloudwire.Have{Seq: map[string]uint64{}} + for _, raw := range envelopes { + env, _ := cloudwire.DecodeEnvelope(raw) + var hdr cloudwire.Header + if err := cloudwire.Unmarshal(env.B, &hdr); err != nil { + t.Fatal(err) + } + have.Boot = hdr.Boot + have.Seq[env.T] = max(have.Seq[env.T], hdr.Seq) + } + return have +} + +func (s *stack) now(tok string) (int, token.Now) { + s.t.Helper() + req, _ := http.NewRequest(http.MethodGet, s.srv.URL+"/v1/now", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := s.srv.Client().Do(req) + if err != nil { + s.t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var n token.Now + if resp.StatusCode == http.StatusOK { + if err := cloudwire.Unmarshal(body, &n); err != nil { + s.t.Fatal(err) + } + } + return resp.StatusCode, n +} + +// waitHeld waits until the service holds n envelopes a device may read. +func waitHeld(t *testing.T, st *store.Store, home string, key appwire.KeyPair, n int) { + t.Helper() + waitFor(t, "the box's envelopes", func() bool { + x, err := st.For(store.Principal{Kind: store.Device, Home: home, DeviceKey: key.Public()}) + if err != nil { + return false + } + snap, _ := x.Snapshot() + return len(snap.Envelopes) >= n + }) +} + +func waitFor(t *testing.T, what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for !ok() { + if time.Now().After(deadline) { + t.Fatalf("waited 3 s for %s", what) + } + time.Sleep(5 * time.Millisecond) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 61cf12f..b21a127 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ // // /v1/box a box's uplink (package uplink) // /v1/app a phone's Noise session to the service (package appsession) +// /v1/app/once a watch's one-shot read, Noise inside one POST (package appsession) // /v1/now a widget's token read (package token) // /source the source of the running service, as AGPL-3.0 asks // /healthz liveness, and nothing about any home @@ -30,7 +31,7 @@ import ( const SourceArchive = "/usr/share/ftw-cloud/source.tar.gz" // Routes are every path the service answers. -var Routes = []string{"/v1/box", "/v1/app", "/v1/now", "/source", "/healthz"} +var Routes = []string{"/v1/box", "/v1/app", "/v1/app/once", "/v1/now", "/source", "/healthz"} // devRoutes adds a local inspection route in binaries built with // -tags ftwclouddev. It is nil in every other build. @@ -41,7 +42,8 @@ type Server struct { Store *store.Store Registry *registry.Registry Uplink *uplink.Uplink - // App is nil on a host without a Noise key; /v1/app then says so. + // App is nil on a host without a Noise key; /v1/app and /v1/app/once + // then say so. App *appsession.Server // Tokens answers /v1/now. Tokens come only over the app session, so // without a Noise key every token read is refused. @@ -55,8 +57,10 @@ func (s *Server) Handler() http.Handler { mux.Handle("/v1/box", s.Uplink) if s.App != nil { mux.Handle("/v1/app", s.App) + mux.HandleFunc("/v1/app/once", s.App.ServeOnce) } else { mux.HandleFunc("/v1/app", notYet) + mux.HandleFunc("/v1/app/once", notYet) } if s.Tokens != nil { mux.Handle("/v1/now", s.Tokens) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f51b6b0..9d6e9d8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -54,7 +54,7 @@ func TestNoRouteReadsAHome(t *testing.T) { s, home := newServer(t) h := s.Handler() paths := append(slices.Clone(Routes), "/", "/v1", "/v1/home", "/admin", "/debug/pprof/", "/metrics", - "/dev/held", "/dev/held?home="+home, "/v1/now?home="+home, "/v1/app?home="+home) + "/dev/held", "/dev/held?home="+home, "/v1/now?home="+home, "/v1/app?home="+home, "/v1/app/once?home="+home) for _, p := range paths { for _, m := range []string{http.MethodGet, http.MethodPost} { w := httptest.NewRecorder() @@ -111,14 +111,20 @@ func TestTheSocketsRefusePlainHTTP(t *testing.T) { } } -// A host without a Noise key serves boxes and says /v1/app is not there yet; -// phones read through the relay meanwhile. +// A host without a Noise key serves boxes and says /v1/app and the one-shot +// read are not there yet; phones read through the relay meanwhile, and a +// watch reads now on the box's exchange. func TestWithoutANoiseKeyTheAppEndpointIsOff(t *testing.T) { s, home := newServer(t) s.App = nil - w := httptest.NewRecorder() - s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/app?home="+home, nil)) - if w.Code != http.StatusNotImplemented { - t.Fatalf("/v1/app without a Noise key: %d", w.Code) + for _, r := range []*http.Request{ + httptest.NewRequest(http.MethodGet, "/v1/app?home="+home, nil), + httptest.NewRequest(http.MethodPost, "/v1/app/once?home="+home, bytes.NewReader([]byte{1})), + } { + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusNotImplemented { + t.Fatalf("%s without a Noise key: %d", r.URL.Path, w.Code) + } } } From 423f5b37c6edc522f931afb1667b46a92f31cf24 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:43:04 +0200 Subject: [PATCH 4/7] docs(deploy): check the watch's door, and the watch's APNs topics Co-Authored-By: Claude Opus 5.5 --- deploy/apns.md | 4 +++- deploy/noise.md | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/deploy/apns.md b/deploy/apns.md index 5a0d347..a0650a4 100644 --- a/deploy/apns.md +++ b/deploy/apns.md @@ -13,7 +13,9 @@ or a log. 2. Name it `FTW Cloud APNs`, tick **Apple Push Notifications service (APNs)**, and choose **Configure**: environment **Sandbox & Production**; key restriction **Team Scoped**, or **Topic Specific** with `energy.ftw.app` - and `energy.ftw.app.push-type.widgets`, the topic widget pushes go to. + and `energy.ftw.app.push-type.widgets`, the topic widget pushes go to, + and for the watch `energy.ftw.app.watchkitapp` and + `energy.ftw.app.watchkitapp.push-type.widgets`. Save, then **Continue** → **Register**. 3. **Download** `AuthKey_.p8`. Apple lets you download it once. Note the 10-character **Key ID**. The Team ID is `L66M368PZ6`. diff --git a/deploy/noise.md b/deploy/noise.md index ddb5304..48a2511 100644 --- a/deploy/noise.md +++ b/deploy/noise.md @@ -1,8 +1,9 @@ # The Noise key phones dial A phone reads its home at `/v1/app` over Noise IK to the service's static -X25519 key, and the app carries the public half. Without the key the service -serves boxes, and `/v1/app` answers 501. Make the private key on a machine +X25519 key, and the app carries the public half. A watch reads at +`/v1/app/once` with the same key. Without the key the service serves boxes, +and both answer 501. Make the private key on a machine signed in to the account and put it straight into AWS Systems Manager Parameter Store; delete the file once it is there. The host reads it from there into tmpfs at every boot. It is never in the @@ -52,7 +53,18 @@ curl -s --http1.1 -o /dev/null -w '%{http_code}\n' --max-time 4 \ It prints 101 and then times out, because the service waits for Noise message 1. Without the key it prints 501. Without the WebSocket headers it -prints 400 either way, so leave them in. The start line in the log, +prints 400 either way, so leave them in. + +The watch's door takes one POST: + +```bash +head -c 96 /dev/urandom | curl -s -o /dev/null -w '%{http_code}\n' --max-time 4 \ + -H 'Content-Type: application/octet-stream' --data-binary @- \ + 'https://cloud.ftw.energy/v1/app/once?home=aaaaaaaaaaaaaaaaaaaaaaaaaa' +``` + +It prints 403, because random bytes are no handshake to the service's key. +Without the key it prints 501. The start line in the log, `ftwcloudd listening … noise_key=…`, shows the public half from step 1. ## 4. Put the public half in the app From 5e5eb47ded3b5882e95ab7515654dc5651b0b08e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:44:37 +0200 Subject: [PATCH 5/7] fix: a one-shot read never waits on the box's socket, and a box back within the minute is asked again Co-Authored-By: Claude Opus 5.5 --- internal/appsession/appsession.go | 29 +++++++++++++++++++++--- internal/appsession/once.go | 2 +- internal/appsession/once_test.go | 16 ++++++++++--- internal/appsession/once_vectors_test.go | 4 +--- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/internal/appsession/appsession.go b/internal/appsession/appsession.go index a5f0ec0..2b0417b 100644 --- a/internal/appsession/appsession.go +++ b/internal/appsession/appsession.go @@ -50,6 +50,8 @@ const ( // updates while a phone looks. The box's watch lasts 60 s, so the ask // comes before it lapses. WatchRenew = 50 * time.Second + // boxWatchLasts is how long the box keeps one watch in force. + boxWatchLasts = 60 * time.Second // roleOwner is the registry's owner role, as the roster names it. // go/protocol does not generate role names yet. roleOwner = "owner" @@ -86,6 +88,9 @@ type Server struct { mu sync.Mutex looking map[string]*look + // watchedUntil is, per home, when the box's watch a one-shot read asked + // for lapses, so a box that comes back before then is asked again. + watchedUntil map[string]time.Time } // Pusher keeps a device's APNs tokens. push.Service is one. dev and scopes @@ -116,7 +121,7 @@ func New(st *store.Store, reg *registry.Registry, w Watcher, key appwire.KeyPair log = slog.Default() } return &Server{store: st, registry: reg, watcher: w, key: key, log: log, now: time.Now, - renewEvery: WatchRenew, looking: map[string]*look{}, onceMessage2: maxNoiseMessage, + renewEvery: WatchRenew, looking: map[string]*look{}, watchedUntil: map[string]time.Time{}, onceMessage2: maxNoiseMessage, once: onceLimits{homes: map[string]int{}, devices: map[string]*onceDevice{}}} } @@ -290,16 +295,34 @@ func (s *Server) renew(home string, stop <-chan struct{}) { } // BoxConnected asks a box that just connected for one-second updates when a -// phone is already looking. The uplink calls it after hello_ok. +// phone is already looking, or a one-shot read asked for them less than a +// minute ago. The uplink calls it after hello_ok. func (s *Server) BoxConnected(home string) { s.mu.Lock() - looking := s.looking[home] != nil + looking := s.looking[home] != nil || s.now().Before(s.watchedUntil[home]) s.mu.Unlock() if looking { s.watcher.Watch(home) } } +// watchOnce asks the box for one-second updates for a one-shot read with +// hz 1, as a looking phone does, without making the read wait on the box's +// socket. The box's watch lasts a minute; a box that comes back within it +// is asked again. +func (s *Server) watchOnce(home string) { + now := s.now() + s.mu.Lock() + for h, until := range s.watchedUntil { + if !now.Before(until) { + delete(s.watchedUntil, h) + } + } + s.watchedUntil[home] = now.Add(boxWatchLasts) + s.mu.Unlock() + go s.watcher.Watch(home) +} + // phoneConn is one phone's session after the handshake. type phoneConn struct { s *Server diff --git a/internal/appsession/once.go b/internal/appsession/once.go index 40ff335..47eef21 100644 --- a/internal/appsession/once.go +++ b/internal/appsession/once.go @@ -255,7 +255,7 @@ func (s *Server) onceReply(home string, dev *store.Handle, env cloudwire.Envelop return nil, nil, http.StatusInternalServerError } if rd.Hz >= cloudwire.HzLooking { - s.watcher.Watch(home) + s.watchOnce(home) } return payload, rest, http.StatusOK case cloudwire.MsgTokenGet: diff --git a/internal/appsession/once_test.go b/internal/appsession/once_test.go index f75d104..a36be8d 100644 --- a/internal/appsession/once_test.go +++ b/internal/appsession/once_test.go @@ -494,13 +494,23 @@ func TestOnceHzAsksTheBoxToHurry(t *testing.T) { t.Fatal("hz 0 asked the box") } h.once(onceOpts{key: h.owner, body: cloudwire.Read{Hz: cloudwire.HzLooking}}) - if h.watch.count() != 1 { - t.Fatalf("hz 1 asked the box %d times", h.watch.count()) - } + waitFor(t, "hz 1 to ask the box", func() bool { return h.watch.count() == 1 }) h.once(onceOpts{key: h.owner, home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", body: cloudwire.Read{Hz: cloudwire.HzLooking}}) + time.Sleep(50 * time.Millisecond) if h.watch.count() != 1 { t.Fatal("a refused read asked a box") } + // A box that comes back within the minute is asked again; after it, + // not. + h.app.BoxConnected(h.home) + if h.watch.count() != 2 { + t.Fatalf("a box back within the minute was asked %d times in all", h.watch.count()) + } + h.app.now = func() time.Time { return time.Now().Add(61 * time.Second) } + h.app.BoxConnected(h.home) + if h.watch.count() != 2 { + t.Fatal("a box back after the minute was asked") + } } // Freshness stays the box's: an hour after the box went quiet, a read diff --git a/internal/appsession/once_vectors_test.go b/internal/appsession/once_vectors_test.go index 2a94c13..0be93d8 100644 --- a/internal/appsession/once_vectors_test.go +++ b/internal/appsession/once_vectors_test.go @@ -148,9 +148,7 @@ func TestOnceAnswersTheGoldenVectors(t *testing.T) { if !bytes.Equal(body, unhex(t, once.Answer)) { t.Fatalf("the answer is not the vectors' bytes:\n got %x\nwant %s", body, once.Answer) } - if w.count() != 1 { - t.Fatalf("hz 1 asked the box %d times", w.count()) - } + waitFor(t, "hz 1 to ask the box", func() bool { return w.count() == 1 }) if len(once.Held) != 2 || once.Held[0] != "live full" || once.Held[1] != "lp" { t.Fatalf("the vectors' have covers %v; this test assumes the full live and the lp", once.Held) } From 914f7b98e10537dfe5e0a7df0870e3e655439197 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 15:02:08 +0200 Subject: [PATCH 6/7] fix: a suspension ends the session first, and no token outlives its device's access From review: - suspend ended the suspended device's session only after dropping its token, so a registry error left the session open. Recheck now runs first. - A token.get that store.For admitted just before a suspension could store a token after the suspension dropped it. Issue now checks the device's access again once the row is stored, and drops the row if it is gone. - TokenPrune deleted by device id alone, so a token the device got at its new epoch meanwhile went too. It now matches the epoch. Co-Authored-By: Claude Opus 5.5 --- internal/appsession/appsession.go | 11 +++++------ internal/appsession/token_test.go | 22 +++++++++++++++++++++ internal/registry/token.go | 27 +++++++++++++++++-------- internal/token/token.go | 10 ++++++++++ internal/token/token_test.go | 33 +++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/internal/appsession/appsession.go b/internal/appsession/appsession.go index 45b24f9..ac554c7 100644 --- a/internal/appsession/appsession.go +++ b/internal/appsession/appsession.go @@ -533,14 +533,13 @@ func (c *phoneConn) suspend(deviceID string, on bool) error { if err := c.s.registry.Suspend(c.home, d.ID, d.Epoch, c.s.now()); err != nil { return err } - // A suspended device's token dies; lifting the suspension does not - // bring it back, the app asks for a new one. + // The suspended device's sessions end first, whatever happens to + // its token. Its token dies; lifting the suspension does not bring + // it back, the app asks for a new one. + c.dev.Recheck() if c.s.Tokens != nil { - if err := c.s.Tokens.Drop(c.home, d.ID); err != nil { - return err - } + return c.s.Tokens.Drop(c.home, d.ID) } - c.dev.Recheck() return nil } return nil diff --git a/internal/appsession/token_test.go b/internal/appsession/token_test.go index 817e90e..9f0c6d7 100644 --- a/internal/appsession/token_test.go +++ b/internal/appsession/token_test.go @@ -3,6 +3,7 @@ package appsession import ( "bytes" "crypto/tls" + "errors" "io" "net/http" "net/http/httptest" @@ -125,3 +126,24 @@ func TestWithoutTokensTokenGetIsIgnored(t *testing.T) { t.Fatalf("the session did not go on: code %d", code) } } + +// The suspended device's session ends even when its token cannot be dropped. +func TestASuspensionEndsTheSessionWhateverTheToken(t *testing.T) { + h := newHarness(t) + h.app.Tokens = failingTokens{} + h.live(true) + owner := h.mustDial(dialOpts{key: h.owner}) + viewer := h.mustDial(dialOpts{key: h.viewer}) + owner.send(cloudwire.MsgSuspend, cloudwire.Suspend{DeviceID: "viewer"}) + if code := viewer.closed(2 * time.Second); code != cloudwire.CloseDenied { + t.Fatalf("the suspended viewer closed with %d", code) + } +} + +type failingTokens struct{} + +func (failingTokens) Issue(string, cloudwire.Device) (cloudwire.Token, error) { + return cloudwire.Token{}, errors.New("no tokens") +} + +func (failingTokens) Drop(string, string) error { return errors.New("the registry is down") } diff --git a/internal/registry/token.go b/internal/registry/token.go index 716dfb6..e0a7312 100644 --- a/internal/registry/token.go +++ b/internal/registry/token.go @@ -66,6 +66,12 @@ func (r *Registry) TokenDrop(home, deviceID string) error { return err } +// TokenDropHash forgets one token by its hash. +func (r *Registry) TokenDropHash(hash []byte) error { + _, err := r.db.Exec(`DELETE FROM tokens WHERE hash = ?`, hash) + return err +} + // TokenPrune keeps only the tokens whose device keep allows at its epoch, as // a new roster says. func (r *Registry) TokenPrune(home string, keep func(deviceID string, epoch uint64) bool) error { @@ -73,21 +79,26 @@ func (r *Registry) TokenPrune(home string, keep func(deviceID string, epoch uint if err != nil { return err } - var gone []string + type entry struct { + id string + epoch int64 + } + var gone []entry for rows.Next() { - var id string - var epoch int64 - if err := rows.Scan(&id, &epoch); err != nil { + var e entry + if err := rows.Scan(&e.id, &e.epoch); err != nil { _ = rows.Close() return err } - if !keep(id, uint64(epoch)) { - gone = append(gone, id) + if !keep(e.id, uint64(e.epoch)) { + gone = append(gone, e) } } _ = rows.Close() - for _, id := range gone { - if err := r.TokenDrop(home, id); err != nil { + // The epoch is part of the match, so a token the device got at its new + // epoch meanwhile stays. + for _, e := range gone { + if _, err := r.db.Exec(`DELETE FROM tokens WHERE home = ? AND device_id = ? AND epoch = ?`, home, e.id, e.epoch); err != nil { return err } } diff --git a/internal/token/token.go b/internal/token/token.go index c499c8b..a27a684 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -62,6 +62,10 @@ func New(st *store.Store, reg *registry.Registry, log *slog.Logger) *Service { // Issue makes a new token for a roster entry, in place of the device's old // one. dev comes from store.For, never from the device's message. +// +// The device's access is checked again once the token is stored: a +// suspension or a roster that landed since store.For admitted the device +// either finds the row and drops it, or is seen here and the row goes. func (s *Service) Issue(home string, dev cloudwire.Device) (cloudwire.Token, error) { raw := make([]byte, tokenBytes) if _, err := rand.Read(raw); err != nil { @@ -72,6 +76,12 @@ func (s *Service) Issue(home string, dev cloudwire.Device) (cloudwire.Token, err if err := s.reg.TokenIssue(row, hash[:]); err != nil { return cloudwire.Token{}, err } + if _, err := s.store.For(store.Principal{Kind: store.Token, Home: home, DeviceID: dev.ID, Epoch: dev.Epoch}); err != nil { + if err := s.reg.TokenDropHash(hash[:]); err != nil { + return cloudwire.Token{}, err + } + return cloudwire.Token{}, store.ErrDenied + } return cloudwire.Token{Token: base64.RawURLEncoding.EncodeToString(raw), ExpiresInS: int64(Life / time.Second)}, nil } diff --git a/internal/token/token_test.go b/internal/token/token_test.go index e5c61a8..1b4bb12 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -337,3 +337,36 @@ func hashOf(t *testing.T, tok string) []byte { sum := sha256.Sum256(raw) return sum[:] } + +// A token asked for by a device that a suspension or roster has just shut +// out is not kept: the access is checked again once the row is stored. +func TestATokenIsNotIssuedPastItsDevicesAccess(t *testing.T) { + h := newHarness(t) + _ = h.reg.Suspend(h.home, "owner", 1, h.clock.t) + if _, err := h.svc.Issue(h.home, cloudwire.Device{ID: "owner", Epoch: 1}); err != store.ErrDenied { + t.Fatalf("a suspended device got a token: %v", err) + } + if _, err := h.svc.Issue(h.home, cloudwire.Device{ID: "owner", Epoch: 2}); err != store.ErrDenied { + t.Fatalf("a device at an epoch the roster does not list got a token: %v", err) + } + var n int + if err := h.reg.TokenPrune(h.home, func(string, uint64) bool { n++; return true }); err != nil || n != 0 { + t.Fatalf("refused tokens left %d rows", n) + } +} + +// A roster that moves a device's epoch drops the token of the old epoch and +// keeps one the device already got at the new epoch. +func TestAPruneKeepsATokenOfTheNewEpoch(t *testing.T) { + h := newHarness(t) + old := h.issue("owner", 1) + r := h.roster(2, protocol.CloudScopes...) + fresh := h.issue("owner", 2) + h.svc.Roster(h.home, r) + if code, _ := h.get("Bearer " + old); code != http.StatusUnauthorized { + t.Fatalf("the old epoch's token read %d", code) + } + if code, _ := h.get("Bearer " + fresh); code != http.StatusOK { + t.Fatalf("the new epoch's token read %d", code) + } +} From b4da95a30948314074f90feaee17d6143f5b3f1a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 15:02:56 +0200 Subject: [PATCH 7/7] fix: a device holds a few push tokens per kind, and a refusal drops only its kind From review: - Widgets need only ftw.live.read, so a viewer may now register, and nothing capped how many tokens a device held. One push then called Apple once per row on the single send goroutine, holding up other homes' alerts. A device now keeps its newest 4 tokens per kind. - PushGone deleted every registration of a token, so Apple refusing the widgets topic also deleted the same token's alert registration. It now deletes only the kind Apple refused. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- internal/push/push.go | 14 ++++++++-- internal/push/widgets_test.go | 51 ++++++++++++++++++++++++++++++++++ internal/registry/push.go | 22 +++++++++++---- internal/registry/push_test.go | 4 +-- 5 files changed, 82 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ca5f15f..7764c6f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ to it yet. further than before, as when tomorrow's arrive, or a car is plugged in, the service sends Apple's widgets push: no data, only "content changed". The widgets then read `/v1/now`. At most 4 an hour per home, since Apple - budgets widget reloads. + budgets widget reloads. A device holds at most 4 tokens of each kind; a + new one pushes out its oldest. - **Widget token.** A device asks for its extension token over its app session with `token.get`. A widget reads `GET /v1/now` with it: the newest `live`, `prices` and `hb` as the box signed them, and nothing else. The diff --git a/internal/push/push.go b/internal/push/push.go index 57a50aa..149027a 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -43,6 +43,11 @@ const ( // raises a few notifications a day; past this, something is wrong and // the rest are dropped. MaxAlertsPerHour = 20 + // MaxTokensPerDevice bounds a device's registrations of one kind. A + // phone holds one token per kind; a few more cover a reinstall before + // Apple says the old token is gone. Past it the oldest goes, so one + // device cannot fill the table or make a push call Apple without end. + MaxTokensPerDevice = 4 // MaxWidgetPushesPerHour bounds a home's widget pushes. Prices arrive // once a day and a car is plugged in a few times; Apple budgets widget // reloads, so a car that flaps between plugged and not must not spend @@ -182,8 +187,11 @@ func (s *Service) Register(home string, dev cloudwire.Device, scopes []string, r (r.Environment != cloudwire.PushSandbox && r.Environment != cloudwire.PushProduction) { return ErrNotForThisDevice } - return s.reg.PushRegister(registry.PushReg{Home: home, DeviceID: dev.ID, Epoch: dev.Epoch, - Token: r.APNsToken, Environment: r.Environment, Kind: r.Kind, At: s.now()}) + if err := s.reg.PushRegister(registry.PushReg{Home: home, DeviceID: dev.ID, Epoch: dev.Epoch, + Token: r.APNsToken, Environment: r.Environment, Kind: r.Kind, At: s.now()}); err != nil { + return err + } + return s.reg.PushTrim(home, dev.ID, r.Kind, MaxTokensPerDevice) } // Unregister forgets one of the device's tokens. @@ -416,7 +424,7 @@ func (s *Service) send(ctx context.Context, home, kindName string, payload []byt sent++ case res.Gone: gone++ - if err := s.reg.PushGone(home, r.Token, res.GoneSince); err != nil { + if err := s.reg.PushGone(home, r.Token, kindName, res.GoneSince); err != nil { s.log.Warn("could not forget a gone token", "err", err) } default: diff --git a/internal/push/widgets_test.go b/internal/push/widgets_test.go index aa81003..251f745 100644 --- a/internal/push/widgets_test.go +++ b/internal/push/widgets_test.go @@ -226,3 +226,54 @@ func TestASuspendedDevicesWidgetsGetNothing(t *testing.T) { t.Fatalf("sent to %+v", got) } } + +// A device holds a few tokens of each kind; past that the oldest goes, so a +// device that registers without end cannot fill the table or make one push +// call Apple without end. +func TestADeviceHoldsAFewTokensPerKind(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + for i := range 50 { + r.clock.advance(time.Millisecond) + if err := r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-"+string(rune('A'+i))); err != nil { + t.Fatal(err) + } + } + _ = r.register(t, r.owner, r.all, "owner-alerts") + regs, _ := r.reg.PushRegs(r.home, KindWidgets) + if len(regs) != MaxTokensPerDevice { + t.Fatalf("the viewer holds %d widget tokens", len(regs)) + } + for _, reg := range regs { + if reg.Token[len(reg.Token)-1] < byte('A'+50-MaxTokensPerDevice) { + t.Fatalf("an old token stayed: %s", reg.Token) + } + } + if alerts, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(alerts) != 1 { + t.Fatal("one device's widget tokens pushed out another kind") + } + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + if got := r.taken(); len(got) != MaxTokensPerDevice { + t.Fatalf("one plug-in called Apple %d times", len(got)) + } +} + +// Apple refusing one kind of push forgets that kind's registration of the +// token, and the token's other kinds stay. +func TestAGoneTokenGoesOnlyForItsKind(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.register(t, r.owner, r.all, "same-token") + _ = r.registerWidgets(t, r.owner, r.all, "same-token") + r.apple.gone["same-token"] = true + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + r.taken() + if widgets, _ := r.reg.PushRegs(r.home, KindWidgets); len(widgets) != 0 { + t.Fatal("a widget token Apple refused stayed") + } + if alerts, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(alerts) != 1 { + t.Fatal("a widgets refusal took the alert registration with it") + } +} diff --git a/internal/registry/push.go b/internal/registry/push.go index decc546..71c93d7 100644 --- a/internal/registry/push.go +++ b/internal/registry/push.go @@ -79,15 +79,25 @@ func (r *Registry) PushRegs(home, kind string) ([]PushReg, error) { return out, rows.Err() } -// PushGone forgets a token Apple says is gone, unless the phone registered -// it again after Apple last knew it valid. -func (r *Registry) PushGone(home string, token []byte, since time.Time) error { +// PushGone forgets one kind's registration of a token Apple says is gone, +// unless the phone registered it again after Apple last knew it valid. The +// token's registrations for other kinds go to other topics and stay. +func (r *Registry) PushGone(home string, token []byte, kind string, since time.Time) error { if since.IsZero() { - _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ?`, home, token) + _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ? AND kind = ?`, home, token, kind) return err } - _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ? AND registered_ms <= ?`, - home, token, since.UnixMilli()) + _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ? AND kind = ? AND registered_ms <= ?`, + home, token, kind, since.UnixMilli()) + return err +} + +// PushTrim keeps a device's newest keep registrations of one kind and +// forgets the rest. +func (r *Registry) PushTrim(home, deviceID, kind string, keep int) error { + _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND kind = ? AND rowid NOT IN ( + SELECT rowid FROM push WHERE home = ? AND device_id = ? AND kind = ? ORDER BY registered_ms DESC, rowid DESC LIMIT ?)`, + home, deviceID, kind, home, deviceID, kind, keep) return err } diff --git a/internal/registry/push_test.go b/internal/registry/push_test.go index ec6ea14..2d3a12c 100644 --- a/internal/registry/push_test.go +++ b/internal/registry/push_test.go @@ -62,13 +62,13 @@ func TestAGoneTokenGoesUnlessRegisteredSince(t *testing.T) { now := time.Unix(1_791_820_800, 0) _ = r.Claim("h1", []byte("pk"), now) _ = r.PushRegister(reg("h1", "phone-a", 1, "tok", now)) - if err := r.PushGone("h1", []byte("tok"), now.Add(-time.Hour)); err != nil { + if err := r.PushGone("h1", []byte("tok"), "alert", now.Add(-time.Hour)); err != nil { t.Fatal(err) } if got, _ := r.PushRegs("h1", "alert"); len(got) != 1 { t.Fatal("a token registered after Apple last knew it was dropped") } - _ = r.PushGone("h1", []byte("tok"), now.Add(time.Hour)) + _ = r.PushGone("h1", []byte("tok"), "alert", now.Add(time.Hour)) if got, _ := r.PushRegs("h1", "alert"); len(got) != 0 { t.Fatal("a gone token was kept") }