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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ 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.
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,
Expand Down
61 changes: 49 additions & 12 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 @@ -35,6 +36,21 @@ 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=<id>`: 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, a roster that has not run
out, no suspension. The box lists a watch with role `watch` and the
scopes it derives; the service checks no role. 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,
Expand All @@ -48,32 +64,53 @@ 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)).

Not built yet, with a marked place in the code:
- **Widget push.** A widget registers its push token with `push.register
{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
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
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:

| Step | What | Where |
|---|---|---|
| C5 | Widget token reads, `/v1/now`, tiles | `internal/server` |
| C6 | Tiles | `internal/server` |
| 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/app/once ◀──Noise IK in one POST── watch
│ ◀── /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,
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.
Expand Down
21 changes: 15 additions & 6 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 @@ -68,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)
Expand Down Expand Up @@ -97,11 +99,13 @@ 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
// 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:
Expand All @@ -116,15 +120,20 @@ func serve(args []string) error {
sender = client
}
pushSvc := push.New(reg, sender, log)
up.OnEvent = pushSvc.Event
up.OnRoster = pushSvc.Roster
pushSvc.WatchApp = *apnsWatch
up.OnHeld = pushSvc.Held
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.
12 changes: 8 additions & 4 deletions deploy/apns.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -12,13 +12,17 @@ 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,
and for the watch `energy.ftw.app.watchkitapp` and
`energy.ftw.app.watchkitapp.push-type.widgets`.
Save, then **Continue** → **Register**.
3. **Download** `AuthKey_<KEYID>.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)

Expand Down
18 changes: 15 additions & 3 deletions deploy/noise.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
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-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
51 changes: 42 additions & 9 deletions internal/apns/apns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading