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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 45 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# FTW Cloud guide

`ftwcloudd` keeps a signed, readable copy of each home that turned FTW Cloud
on, and serves it to that home's phones. Read [README.md](README.md) first,
`ftwcloudd` is the one door between a home's phones and its box: it carries
each phone's own Noise session with the box, and keeps a signed, readable
copy of each home whose owner turned that on. Read [README.md](README.md) first,
then the normative
[FTW Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md)
and [ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
Expand All @@ -10,22 +11,33 @@ maintains it.

## Invariants

- **FTW Cloud reads and never controls.** It sends a box only `challenge`,
`hello_ok`, `watch`, `resync` and `purged` (`internal/uplink/downlink.go`).
Commands go from the app to the box over Noise, never through here.
- **FTW Cloud forwards ciphertext it cannot read, and never chooses an
action.** It sends a box only `challenge`, `hello_ok`, `watch`, `resync`,
`purged` and `tun` (`internal/uplink/downlink.go`). Commands go from the app
to the box inside the phone's own Noise session, which rides here as `tun`
under the device id the service's own handshake proved, never one a phone
names.
- **One way to a home:** `store.For(principal)`. No listing, no second lookup,
no principal for staff. A new route needs a row in `server.Routes` and must
pass `TestNoRouteReadsAHome`.
pass `TestNoRouteReadsAHome`. The one other way in is `store.Seed`, called
once at start: it puts back the rosters the registry holds, each checked
against its home's key, and reads nothing out.
- **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, 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. 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.
never from a body field. Every later box message but `tun` must verify
with that key. `tun` is unsigned: its `d` is a phone's ciphertext, which
the service routes by `dev` and `sid` and never reads.
- **No readings on disk.** Memory holds readings, and only while the roster's
grant serves a copy; SQLite holds only the home id, the box key, the signed
roster, owners' suspensions, tombstones, APNs registrations, the hashes of
extension tokens and when each away box's alert is due. Nothing logs
addresses.
- **A phone can only narrow, or talk to its own box.** On `/v1/app` it can
say whether it is looking, an owner can suspend a roster device, and a
device the roster lists with `tun` can send its box session as `tun`. 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, or once for
a pairing code the roster lists by its hash.
- **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,
Expand All @@ -38,8 +50,24 @@ maintains it.
- Check open PRs on the files you touch; an open PR has right of way.
- A wire change is a pair: `srcfl/ftw` (`go/protocol` and its golden vectors)
first, then this repository, moving the pin in `go.mod`. Say so in both PRs.
- Keep the service small. The budget is about 3,000 lines of Go; tiles go
first if it is overrun.
- Keep the service small. The old budget was about 3,000 lines of Go in all,
and the service is over it. The one-door plan gives each part a budget of
its own, and the numbers are Fredrik's call. Until he sets them, a PR that
grows a part says by how much. Lines of Go without tests, at
srcfl/ftw-cloud#15:

| Part | Lines |
|---|---|
| `internal/appsession` (app session, one-shot read) | 1,020 |
| `internal/store` | 807 |
| `internal/uplink` | 719 |
| `internal/registry` | 694 |
| `cmd/ftwcloudd` | 553 |
| `internal/push` | 542 |
| `internal/apns` | 299 |
| `internal/server` | 205 |
| `internal/token` | 170 |
| All | 5,009 |
- Nothing is deployed from here without the owner. `deploy/` describes the
running host; it does not run itself.
- Keep task breakdowns and agent notes out of the repository.
Expand Down
62 changes: 48 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# FTW Cloud

FTW Cloud keeps a signed, readable copy of each home that turns it on, and
serves it to that home's phones. It reads and never controls. The box opens
the connection, picks and signs every message, and can revoke at any time.
FTW Cloud is the one door between a home's phones and its box. It carries
each phone's own Noise session with the box as ciphertext it cannot read or
forge, and keeps a signed, readable copy of each home whose owner turns that
on. It never chooses an action. The box opens the connection, picks and signs
every message but the phones' ciphertext, and can revoke at any time.

This is `ftwcloudd`, the service. The decision is
[ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
Expand All @@ -21,10 +23,40 @@ 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.
- What the service holds follows the newest roster's grant, never `hello`:
the readable copy can turn on or off while the uplink stays up.
- 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.
owners' suspensions, APNs registrations, the hashes of extension tokens
and when an away box's alert is due. A box whose owner turns the readable
copy off gets that copy deleted and a signed receipt (`purged {what:
"copy"}`); a box whose link ends gets its home deleted and a receipt.
- **A restart forgets no one.** At start the service reads every signed
roster back from the registry and checks it with the home's key, so a
home's phones are admitted before its box reconnects, and the wait for
`box.unreachable` resumes where it was. A connection becomes the home's
only after `hello_ok` has gone, so nothing reaches a box ahead of it.
- **The tunnel.** A box that lists `tun` in `hello.caps` takes its phones'
sessions: a phone sends `tun {sid, d}` on its app session, where `d` is its
`ftw.session.v1` session with the box byte for byte; the service adds the
device id of the roster entry its own handshake matched and sends `tun
{dev, sid, d}` down `/v1/box`, and the box's answers come back the same
way. The box session's first message may ride in the app session's message
1 (`now.tun`), so it is up one round trip after TLS. An empty `d` ends a
session: the box hears when the phone's app session ends, and the phone
when the box's uplink does, or when the box dials again. From the box,
tun rides a queue of its own per phone session, apart from the readings;
from a phone it is written straight to the box's socket. Each direction
has limits per home: 240 messages then 60 a second, 4 MiB then 512 KiB a
second. Past them the service drops and counts, and logs the count each
minute; a box at twice them is closed, and so is a phone. A roster device
needs `tun` or a readable copy to be admitted at all.
- **Pairing through the service.** While a QR code is on the box's screen,
its roster lists the code's hash. A new phone proves the hash in `now.pair`
and is admitted once, as a device that may only open a tunnel; the box
checks the code itself inside the phone's handshake. When the roster lists
the new phone its session takes that entry and stays open.
- The web app may open `/v1/app` from `https://app.ftw.energy`; any other
`Origin` is refused.
- `/source` serves the source of the running build, as AGPL-3.0 asks, and
names its commit and image digest.
- A phone reads its home at `/v1/app?home=<id>`: Noise IK to the service's
Expand Down Expand Up @@ -95,7 +127,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)
└────▶ registry (SQLite: key, roster, tombstones, suspensions, APNs, token hashes, away)
```

- **The home id is a hash of the box's key.** It comes from the key that
Expand All @@ -107,13 +139,15 @@ 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 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.
- **A phone or a watch can narrow, and talk to its own box.** A phone can
say whether it is looking, an owner can suspend a device in the roster, and
a phone sends its box session as `tun`, which the service cannot read. A
watch can read, ask for its token and register for push.
- **The service sends six things.** `challenge`, `hello_ok`, `watch`,
`resync`, `purged` and `tun` are the whole downlink. The first five ask
the box for nothing; `tun` carries a phone's ciphertext, which the box
judges as it judges the same session over the relay. The box closes the
uplink on anything else.
- **No TLS terminator in front.** Box and service bind the handshake to the
TLS session, so `ftwcloudd` ends TLS itself. A TCP load balancer is fine.
- **Root on the host can read memory.** The privacy notice says so.
Expand Down
16 changes: 14 additions & 2 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ func serve(args []string) error {
st := store.New(nil)
st.UseSuspensions(reg.Suspended)
up := uplink.New(st, reg, key, log)
// A restart is a deploy, and commands ride through here: phones are
// admitted by the rosters their boxes signed before, without waiting
// for every box to reconnect.
if n, err := up.Restore(); err != nil {
return err
} else if n > 0 {
log.Info("rosters restored from the registry", "homes", n)
}
tokens := token.New(st, reg, log)
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log}

Expand Down Expand Up @@ -134,13 +142,14 @@ func serve(args []string) error {
srv.App = appsession.New(st, reg, up, noise, log)
srv.App.Push = pushSvc
srv.App.Tokens = tokens
srv.App.Tunnel = up
}
app := srv.App
up.OnConnect = func(home string) {
up.OnConnect = func(home string, conn uint64) {
if app != nil {
app.BoxConnected(home)
}
pushSvc.BoxConnected(home)
pushSvc.BoxConnected(home, conn)
}

// TLS ends here and nowhere else: each box binds its handshake to this
Expand Down Expand Up @@ -179,6 +188,9 @@ func serve(args []string) error {
defer stop()
go srv.Upkeep(ctx, time.Minute)
go pushSvc.Run(ctx)
if err := pushSvc.Resume(); err != nil {
log.Warn("could not restart the dead man's switch", "err", err)
}
if *devPush != "" {
if err := serveDevPush(ctx, *devPush, reg, pushSvc, sender, log); err != nil {
return err
Expand Down
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-20261014110822-7c72a5b33aa0
github.com/srcfl/ftw/go/protocol v0.0.0-20261015164244-f5cdb92058c1
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-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/srcfl/ftw/go/protocol v0.0.0-20261015164244-f5cdb92058c1 h1:JLItMdT1tX3ejgmW80VXcwZ77wBPY7WT2ewMGfPLDIc=
github.com/srcfl/ftw/go/protocol v0.0.0-20261015164244-f5cdb92058c1/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
Loading
Loading