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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ 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 and tombstones. Nothing logs addresses.
id, the box key, the signed roster, owners' suspensions and tombstones.
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.
- **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
64 changes: 52 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)
## What works today (C2, C3, C4)

- 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,35 +21,59 @@ 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 and the signed roster.
A box that turns FTW Cloud off gets its home deleted and a signed receipt.
- 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
and a signed receipt.
- `/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
Noise key, with the home id in the prologue. The service admits only a
phone whose key the box listed in its signed roster, and no owner has
suspended. Handshake message 2 carries the snapshot from memory; after it
each new box envelope the phone may read follows on the same socket,
unchanged. A roster change, a suspension, an expired roster or a purge
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.
- 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,
wrapped unchanged: the phone checks the signature, writes its own words and
picks the buttons from the verified kind. Every alert has the same aps,
priority and push type and no collapse id. Ten minutes without the box's
socket sends `box.unreachable`, unless the box said `bye` for an update,
which holds it back for an hour. A phone registers its token over its app
session with `push.register`. Tokens are bound to roster entries that may
read `ftw.events.read`, get nothing while an owner has suspended the
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:

| Step | What | Where |
|---|---|---|
| C3 | The app's Noise session, `/v1/app` | `internal/server`, `store.For` with a `Device` principal |
| C4 | Alerts over APNs, `box.unreachable` | not started |
| C5 | Widget token reads, `/v1/now`, 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)
└────▶ registry (SQLite: key, roster, tombstones)
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)
```

- **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 (C3) may
`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
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.
- **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 All @@ -61,9 +85,10 @@ box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory)

```bash
go build -tags ftwclouddev -o ftwcloudd ./cmd/ftwcloudd
./ftwcloudd keygen -out service.key # prints the public key
./ftwcloudd keygen -out service.key # prints the public key, for Core
./ftwcloudd keygen -noise -out noise.key # prints the Noise public key, for the app
./ftwcloudd -listen 127.0.0.1:18443 -state ./state -service-key service.key \
-tls-cert leaf.pem -tls-key leaf.key
-noise-key noise.key -tls-cert leaf.pem -tls-key leaf.key
```

Build Core from `srcfl/ftw` against it. A release build names no service, so
Expand All @@ -85,15 +110,30 @@ curl -X POST http://127.0.0.1:8080/api/cloud/grant \
curl http://127.0.0.1:8080/api/cloud/preview
```

Without `-noise-key` (or with the file missing) the service still serves
boxes, and `/v1/app` answers 501: phones read through the relay until a
Noise key is installed. A host that should serve phones needs the key as a
second secret beside the service key.

`-tags ftwclouddev` adds `/dev/held?home=<id>` on loopback, which shows what
the service holds so a test can compare it with the preview. Release builds
do not have it.
the service holds so a test can compare it with the preview, and
`-dev-push-listen 127.0.0.1:18444`, where `POST /dev/push/register` with
`{home, deviceId, apnsToken (hex), environment}` registers a token for a
device in the home's roster, as the app session does; `POST /dev/push/raw`
with `{home, payload}` sends a payload as it is, so a test can put a forged
one through Apple. `-dev-apns-capture <dir>` writes alerts to files instead
of sending them. `-apns-dir` names a
directory holding `key.p8`, `key-id` and `team-id`; without it nothing is
sent. Release builds have neither dev route.

## Develop

```bash
go vet ./... && go test -race ./...
go test -run XXX -fuzz FuzzBoxMessage -fuzztime 30s ./internal/uplink
go test -run XXX -fuzz FuzzPhoneMessage -fuzztime 30s ./internal/appsession
go test -run TestTheSnapshotArrivesFast -v ./internal/appsession # loopback p50/p95
go test -run XXX -bench BenchmarkSnapshot ./internal/appsession
bash scripts/check-imports.sh
```

Expand Down
169 changes: 169 additions & 0 deletions cmd/ftwcloudd/devpush_dev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//go:build ftwclouddev

package main

import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"slices"
"sync"
"time"

"github.com/srcfl/ftw-cloud/internal/apns"
"github.com/srcfl/ftw-cloud/internal/push"
"github.com/srcfl/ftw-cloud/internal/registry"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

// serveDevPush listens on loopback for two routes. Release builds have
// neither.
//
// - POST /dev/push/register registers an APNs token for a device in a
// home's signed roster, as the app's session will, so an end-to-end test
// can run before the app session can.
// - POST /dev/push/raw {home, payload} sends a payload as it is to the
// home's registered phones, so a test can put a forged one through Apple
// and see the phone refuse it.
func serveDevPush(ctx context.Context, addr string, reg *registry.Registry, svc *push.Service, sender push.Sender, log *slog.Logger) error {
host, _, err := net.SplitHostPort(addr)
if err != nil || !net.ParseIP(host).IsLoopback() {
return errors.New("-dev-push-listen must be a loopback address")
}
mux := http.NewServeMux()
mux.HandleFunc("POST /dev/push/register", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Home string `json:"home"`
DeviceID string `json:"deviceId"`
Token string `json:"apnsToken"`
Environment string `json:"environment"`
}
if json.NewDecoder(r.Body).Decode(&req) != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
token, err := hex.DecodeString(req.Token)
if err != nil {
http.Error(w, "apnsToken is hex", http.StatusBadRequest)
return
}
dev, scopes, ok := rosterDevice(reg, req.Home, req.DeviceID)
if !ok {
http.Error(w, "no such device in the home's roster", http.StatusNotFound)
return
}
if err := svc.Register(req.Home, dev, scopes, cloudwire.PushRegister{
APNsToken: token, Environment: req.Environment, Kind: cloudwire.PushKindAlert}); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /dev/push/raw", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Home string `json:"home"`
Payload json.RawMessage `json:"payload"`
}
if json.NewDecoder(r.Body).Decode(&req) != nil || len(req.Payload) == 0 {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if sender == nil {
http.Error(w, "no APNs key", http.StatusServiceUnavailable)
return
}
regs, err := reg.PushRegs(req.Home, cloudwire.PushKindAlert)
if err != nil || len(regs) == 0 {
http.Error(w, "no phone registered for that home", http.StatusNotFound)
return
}
var out []string
for _, x := range regs {
res, err := sender.Send(r.Context(), apns.Notification{Environment: x.Environment, DeviceToken: x.Token,
Payload: req.Payload, Expiration: time.Now().Add(10 * time.Minute)})
out = append(out, fmt.Sprintf("%d %s %v", res.Status, res.Reason, err))
}
_ = json.NewEncoder(w).Encode(out)
})
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
_ = srv.Close()
}()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Warn("dev push route stopped", "err", err)
}
}()
log.Warn("development build: tokens can be registered by hand", "addr", addr)
return nil
}

// captureSender writes each alert to dir as <n>.json, {environment,
// deviceToken, payload}, and tells the service Apple took it. A test hands
// the payload to a simulator with xcrun simctl push.
func captureSender(dir string) (push.Sender, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &capture{dir: dir}, nil
}

type capture struct {
mu sync.Mutex
dir string
n int
}

func (c *capture) Send(_ context.Context, n apns.Notification) (apns.Result, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
raw, err := json.Marshal(map[string]any{"environment": n.Environment,
"deviceToken": hex.EncodeToString(n.DeviceToken), "payload": json.RawMessage(n.Payload)})
if err != nil {
return apns.Result{}, err
}
name := filepath.Join(c.dir, fmt.Sprintf("%04d.json", c.n))
if err := os.WriteFile(name+".tmp", raw, 0o600); err != nil {
return apns.Result{}, err
}
return apns.Result{Status: http.StatusOK}, os.Rename(name+".tmp", name)
}

// rosterDevice finds a device in the roster the box last signed, with its
// effective scope.
func rosterDevice(reg *registry.Registry, home, id string) (cloudwire.Device, []string, bool) {
h, ok, err := reg.Get(home)
if err != nil || !ok || h.Roster == nil {
return cloudwire.Device{}, nil, false
}
env, err := cloudwire.DecodeEnvelope(h.Roster)
if err != nil {
return cloudwire.Device{}, nil, false
}
var r cloudwire.Roster
if cloudwire.Unmarshal(env.B, &r) != nil {
return cloudwire.Device{}, nil, false
}
for _, d := range r.Devices {
if d.ID != id {
continue
}
var scopes []string
for _, s := range d.Scopes {
if slices.Contains(r.Grant.Scopes, s) {
scopes = append(scopes, s)
}
}
return d, scopes, true
}
return cloudwire.Device{}, nil, false
}
22 changes: 22 additions & 0 deletions cmd/ftwcloudd/devpush_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build !ftwclouddev

package main

import (
"context"
"errors"
"log/slog"

"github.com/srcfl/ftw-cloud/internal/push"
"github.com/srcfl/ftw-cloud/internal/registry"
)

// captureSender exists only in development builds.
func captureSender(string) (push.Sender, error) {
return nil, errors.New("-dev-apns-capture needs a build with -tags ftwclouddev")
}

// serveDevPush exists only in development builds.
func serveDevPush(context.Context, string, *registry.Registry, *push.Service, push.Sender, *slog.Logger) error {
return errors.New("-dev-push-listen needs a build with -tags ftwclouddev")
}
Loading
Loading