Skip to content

FTW Cloud C4: alerts through APNs, signed by the box - #4

Closed
frahlg wants to merge 5 commits into
mainfrom
claude/cloud-c4-eb03d7
Closed

frahlg wants to merge 5 commits into
mainfrom
claude/cloud-c4-eb03d7

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

FTW Cloud step C4 in the service: a box's signed events reach its phones through APNs, and a box that has been away for ten minutes is reported out of reach. The service reads and never controls, and it never chooses what a phone shows: the payload carries the box's envelope unchanged, and the phone checks the signature, writes its own words and picks the buttons from the verified kind.

Paired pull requests

  • srcfl/ftw: srcfl/ftw#1602 (Core K5, the event class, bye{why:"update"}, new kinds, and go/protocol/cloudwire/push.go with vectors). This PR pins go/protocol at its commit 8cca4203.
  • srcfl/ftw-app: srcfl/ftw-app#18 (M3/M4: the notification service extension, categories, registration).
  • srcfl/ftw-webapp: srcfl/ftw-webapp#82 (the catalogue gains charging.goal_at_risk).
  • C3, the app session, is in flight in this repo: srcfl/ftw-cloud#5. It owns internal/appsession and internal/server; this PR touches neither. Both add the same OnConnect to the uplink; when both land, main.go calls app.BoxConnected and pushSvc.BoxConnected from it, and C3's appsession.apply gains two cases for push.register and push.unregister.

What changes

internal/apns (stdlib only):

  • HTTP/2 to Apple's provider API. A provider token (ES256 JWT from the team's .p8) is reused for 40 minutes and renewed once on ExpiredProviderToken/InvalidProviderToken.
  • Every alert goes with apns-push-type: alert, apns-priority: 10 and no collapse id, so the request says nothing a kind would.
  • 410, BadDeviceToken and DeviceTokenNotForTopic mark a token gone. A 410 carries Apple's timestamp, so a token the phone registered again later is kept.

internal/registry/push.go:

  • A push table bound to roster entries: home, device id, epoch, token, environment, kind.
  • REFERENCES homes(home) ON DELETE CASCADE with foreign_keys on, so a purge and the 90-day expiry take the registrations with the home without touching Purge or Expire.
  • Registrations go 30 days after the box's last roster.

internal/push:

  • Register only for a device whose effective scope (roster scopes ∩ grant) reads ftw.events.read. A viewer, or an owner under a grant without events, gets ErrNotForThisDevice.
  • Roster drops the tokens of devices the new roster drops, moves to a new epoch, or narrows.
  • Event wraps the box's envelope as it arrived (cloudwire.AlertPayload) and sends it to every registered phone. The cap is 20 alerts per home per hour, and Apple keeps an event at most 10 minutes; after that a phone would show only the generic line.
  • Dead man's switch: box.unreachable 10 minutes after the box's socket ends, once per outage. After bye{why:"update"} it is held back for an hour. A box that comes back stops it, and a revoke ends it. It is the one alert the service writes, with no arguments and no buttons.
  • Nothing logs a token or a home id.

internal/uplink:

  • OnConnect (the same declaration C3 has), plus OnEvent, OnRoster and OnGone.
  • OnGone fires only for the home's current connection, with the reason the box gave in its bye.

ftwcloudd:

  • -apns-dir holds key.p8, key-id and team-id. Without them the service runs and sends nothing.
  • Development builds (-tags ftwclouddev) get two flags, so an end-to-end test can run before the app session and the key exist:
    • -dev-push-listen: register a token for a roster device by hand, on loopback;
    • -dev-apns-capture: write each alert to a file instead of sending it to Apple.

deploy/:

  • apns.sh installs a unit that reads the key from SSM Parameter Store into tmpfs before Docker starts.
  • compose.yml mounts that directory read-only.
  • apns.md is the owner's steps.
  • The host role needs nothing new. AmazonSSMManagedInstanceCore allows ssm:GetParameter on * (iam simulate-principal-policy says allowed), and the account's aws/ssm key policy allows decrypt through SSM for every principal in the account. Secrets Manager would have needed a policy change, so the key goes in Parameter Store.
  • deploy: images built in CI, run by digest; service key in Secrets Manager #3 moves the service key to Secrets Manager with a one-secret inline policy. The APNs key could follow it: one more ARN in deploy/host-policy.json and get-secret-value in apns.sh (deploy/apns.md says how). Both PRs add a line after -acme-email in compose.yml, so whichever lands second resolves one small conflict.

Go without tests: 2,282 lines (budget about 3,000, before C3's app session).

Tests

go vet ./... && go test -race ./...     ok (apns, push, registry, server, store, uplink)
go test -tags ftwclouddev ./...         ok
bash scripts/check-imports.sh           only go/protocol from srcfl/ftw (2 packages)

End-to-end, on this laptop

Before the APNs key existed

Setup

  • Core from srcfl/ftw claude/cloud-c4-core (19ea338c, fe359327), with the endpoint, a local service key and a dev root set by -ldflags -X.
  • ftwcloudd from srcfl/ftw-cloud claude/cloud-c4-eb03d7, built with -tags ftwclouddev and -dev-apns-capture. That flag writes each alert to a file instead of sending it to Apple.
  • The house is simulated: sim-ferroamp, sim-sungrow, and sim-ocpp as an Easee charger.
  • The app from srcfl/ftw-app claude/ios-m3-notify (Debug), on the simulator "FTW M3" (iPhone 17, iOS 26.5, Swedish).
    • It was paired with that Core over the production relay (-FTWPairLink).
    • Its sandbox APNs token was registered through the dev route.
    • The box key and loadpoint table were seeded with -FTWCloudHome …, standing in for C3's K11.
  • Delivery: a script hands each captured payload to the simulator with xcrun simctl push.
Step What happened
Grant POST /api/cloud/grant {scopes: [ftw.live.read, ftw.events.read]} on loopback. The uplink connected; home fid4fo4j….
Plug in, 08:35:37 Core sent charging.connected at 08:37:09: level assumed, no goal, after the 90 s settle. The service sent the alert to 1 phone 11 ms later.
The payload cloudwire.ReadAlert with the box's key: verdict=event kind=charging.connected lp=7edYjyHw-MQ seq=108. The aps is the generic one, and the event is the box's envelope unchanged.
80 % in the background On the lock screen the notification showed 20 %, 50 %, 80 %, Öppna (category added as the extension sets it). Tapped 80 % at 08:40:46. The app checked the payload again, mapped 7edYjyHw-MQ → garage and sent loadpoint.soc.set. The box's result came in 1,002 ms: setLevel(pct: 80.0) answered in 1002 ms, push: ftw.level.80: done: FTW planerar nu från 80 %. The box then read soc_source: inferred, current_soc: 0.80.
A forged payload One byte of the signed body flipped (charging.connected → charging.bonnected). ReadAlert gave verdict=generic. In the app, a tap on 20 % logged push: ftw.level.20 not sent: generic, and the box stayed at 80 %.
charging.interrupted Charge now at 11 kW from 08:41:40. The charger stalled at 08:52:05 with the car still asking. Core sent the event at 08:53:39 (90 s to confirm), and the service sent it 3 ms later. ReadAlert gave verdict=event kind=charging.interrupted.
Planned update POST /api/restart at 08:54:00. Core sent bye{why:"update"} and exited; it has no supervisor here. 11.5 minutes later no alert had left the service.
A crash, for contrast Core came back, then kill -9 at 09:06:18. The service logged a box went away bye="" out_of_reach_after=10m0s, and at 09:16:18 a box is out of reach; telling its phones, with the alert to 1 phone. Its payload is {"ftw":{"v":1,"home":"fid4fo4j…","kind":"box.unreachable"}}, and ReadAlert gives verdict=unreachable.

Through Apple's sandbox, with the real key

The same house, with ftwcloudd sending to api.sandbox.push.apple.com. Key K9299SY46H, team L66M368PZ6, read from SSM Parameter Store into a temporary folder and deleted afterwards; nothing printed it. The extension (FTWNotify) now ran for every push.

Step What happened
box.unreachable (sent with /dev/push/raw) Apple 200. The extension logged push: unreachable. The lock screen showed TIDSKÄNSLIG · Din box går inte att nå – "Den har slutat svara. Strömavbrott, eller så är internet hemma nere."
Plug in, 09:31:29 Core notifications: sent event=charging.connected at 09:32:59.870. The service sent to Apple at 09:33:00.032, and the extension logged push: event charging.connected at 09:33:00.138, 268 ms after Core. On screen: Bilen är inkopplad – "Öppna för att se laddningen, kolla bilens nivå och välja klartid."
Buttons from the extension 20 %, 50 %, 80 % and Öppna, from the verified kind; nothing in the payload names a category.
80 % in the background, 09:34:09 setLevel(pct: 80.0) answered in 990 ms, push: ftw.level.80: done: FTW planerar nu från 80 %. The box went from soc_source: assumed, current_soc: 0.20 to inferred, 0.80.
A forged payload through Apple The last real event from the box's preview, one byte flipped (charging.bonnected), sent with /dev/push/raw. Apple 200; the extension logged push: generic. The lock screen showed "Något hände hemma / Öppna FTW för att se vad.", and "Visa" opened it with no buttons.
charging.interrupted Charge now from 09:35:07, charger stalled at 09:45:30. Core sent at 09:47:01.877, and the extension logged push: event charging.interrupted at 09:47:02.076. On screen: TIDSKÄNSLIG · Laddningen avbröts – "Bilen slutade ladda innan den var klar." Buttons: Ladda nu, Öppna.
Ladda nu in the background iOS started the app cold. chargeNow answered in 9433 ms, push: ftw.chargeNow: done: FTW har startat laddningen. Väntar på att bilen tar ström.

The planned update and the crash ran in the first part. They never reach Apple: no alert left the service for the update, and the crash's box.unreachable is the payload shown in the first row here. Screenshots are kept with the test, outside the repository.

Not done

  • Joining C3 and C4 (agreed with C3), after FTW Cloud C4: alerts through APNs, signed by the box #4 and feat(app): phones read their home over Noise at /v1/app (C3) #5 land:
    • appsession.Server gets a Push field. phoneConn.apply gets two cases that call Register and Unregister with c.dev.Device() and c.dev.Scopes(). ErrNotForThisDevice keeps the session open; a decode error closes it with CloseProtocol.
    • In main.go, OnConnect calls both App.BoxConnected (App may be nil) and pushSvc.BoxConnected.
    • Alerts skip devices an owner has suspended (C3's registry.Suspended).
      Until then the dev route registers tokens.
  • After a restart of ftwcloudd the dead man's switch knows only boxes that connect again. A box that was connected before and never comes back is not reported. Persisting "last seen" would fix it.
  • Widget and Live Activity pushes (C5, C6).

Nothing here is deployed. The owner runs deploy/apns.sh after putting the key in Parameter Store (deploy/apns.md).

🤖 Generated with Claude Code

frahlg and others added 5 commits October 13, 2026 08:14
A box's signed event now reaches its phones through Apple, wrapped
unchanged; the phone checks the signature, writes its own words and picks
the buttons from the verified kind. The service chooses none of that.

- internal/apns: HTTP/2 to Apple's provider API with an ES256 token from
  the team's .p8, renewed every 40 minutes and once on
  ExpiredProviderToken. 410, BadDeviceToken and DeviceTokenNotForTopic
  mark a token gone. Every alert has the same push type and priority and
  no collapse id.
- internal/registry: a push table bound to roster entries (home, device
  id, epoch), cascading with the home on purge and expiry, and expiring
  30 days after the box's last roster.
- internal/push: registration (only devices whose effective scope reads
  events), roster pruning, fan-out with an hourly cap per home, and the
  dead man's switch: box.unreachable after ten minutes away, held back for
  an hour after bye{why:"update"}, never for a revoke.
- uplink: OnConnect (as C3 has it), OnEvent, OnRoster and OnGone hooks.
- ftwcloudd: -apns-dir; without a key nothing is sent. Development builds
  get -dev-push-listen to register a token by hand before the app session
  can.
- deploy: apns.sh reads the key from Parameter Store into tmpfs at boot;
  the host role can already read it. apns.md is the owner's steps.

Pins go/protocol at srcfl/ftw 8cca4203.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-dev-apns-capture <dir> writes each alert as JSON instead of sending it to
Apple, so an end-to-end test can hand it to a simulator with xcrun simctl
push before an APNs key exists. Release builds refuse the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neither line names the home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a key

Like the service key's fetch in #3: at boot the network and the instance's
credentials may lag, so a read is tried for a minute. A parameter that does
not exist ends it at once, so a host without a key does not hold Docker back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /dev/push/raw on the loopback dev listener sends a payload unchanged
to a home's registered phones, so the end-to-end test can put a forged
alert through Apple's sandbox and watch the phone's extension refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg

frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Included in #7 (C3 and C4 joined), squash-merged to main.

@frahlg frahlg closed this Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant