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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@ both in `srcfl/ftw`. Both are proposed; nothing here is deployed.
- 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.
- `/source` serves the source of the running build, as AGPL-3.0 asks.
- **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. Tokens are bound to roster entries that
may read `ftw.events.read` 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 |

Expand Down Expand Up @@ -83,8 +91,15 @@ curl http://127.0.0.1:8080/api/cloud/preview
```

`-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 will; `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

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")
}
64 changes: 63 additions & 1 deletion cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"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-cloud/internal/server"
"github.com/srcfl/ftw-cloud/internal/store"
Expand Down Expand Up @@ -59,6 +62,10 @@ func serve(args []string) error {
certFile := fs.String("tls-cert", "", "certificate file, instead of ACME (local tests)")
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")
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)

harden()
Expand All @@ -74,7 +81,30 @@ func serve(args []string) error {
defer reg.Close()

st := store.New(nil)
srv := &server.Server{Store: st, Registry: reg, Uplink: uplink.New(st, reg, key, log), Log: log}
up := uplink.New(st, reg, key, log)
srv := &server.Server{Store: st, Registry: reg, Uplink: up, 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.
var sender push.Sender
switch client, err := loadAPNs(*apnsDir, *apnsTopic); {
case err != nil:
return err
case *devCapture != "":
if sender, err = captureSender(*devCapture); err != nil {
return err
}
case client == nil:
log.Warn("APNs off: no key in " + *apnsDir)
default:
sender = client
}
pushSvc := push.New(reg, sender, log)
up.OnConnect = pushSvc.BoxConnected
up.OnEvent = pushSvc.Event
up.OnRoster = pushSvc.Roster
up.OnGone = pushSvc.BoxGone

// TLS ends here and nowhere else: each box binds its handshake to this
// TLS session, so a terminating proxy in front would break it. A plain
Expand Down Expand Up @@ -111,6 +141,12 @@ func serve(args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go srv.Upkeep(ctx, time.Minute)
go pushSvc.Run(ctx)
if *devPush != "" {
if err := serveDevPush(ctx, *devPush, reg, pushSvc, sender, log); err != nil {
return err
}
}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand All @@ -128,6 +164,32 @@ func serve(args []string) error {

func log0() *log.Logger { return log.New(io.Discard, "", 0) }

// loadAPNs reads Apple's key from dir: key.p8, key-id and team-id, as the
// host writes them from SSM Parameter Store at boot. No directory or no key
// is no sender; a key that is there and wrong is an error.
func loadAPNs(dir, topic string) (*apns.Client, error) {
p8, err := os.ReadFile(filepath.Join(dir, "key.p8"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("apns key: %w", err)
}
read := func(name string) (string, error) {
b, err := os.ReadFile(filepath.Join(dir, name))
return strings.TrimSpace(string(b)), err
}
keyID, err := read("key-id")
if err != nil {
return nil, fmt.Errorf("apns key id: %w", err)
}
teamID, err := read("team-id")
if err != nil {
return nil, fmt.Errorf("apns team id: %w", err)
}
return apns.New(apns.Config{Key: p8, KeyID: keyID, TeamID: teamID, Topic: topic})
}

func loadServiceKey(path string) (*ecdsa.PrivateKey, error) {
raw, err := os.ReadFile(path)
if err != nil {
Expand Down
Loading