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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -117,14 +119,18 @@ 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
// Noise key only the development route registers one.
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) {
Expand Down
8 changes: 5 additions & 3 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
61 changes: 57 additions & 4 deletions internal/appsession/appsession.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -486,7 +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
}
// 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 {
return c.s.Tokens.Drop(c.home, d.ID)
}
return nil
}
return nil
Expand Down
Loading
Loading