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
5 changes: 5 additions & 0 deletions .changeset/ftw-cloud-app-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Let phones read their home from FTW Cloud. While FTW Cloud is on, the box tells every paired phone, over its own encrypted session, the key it signs with and the names behind its anonymous ids, so a phone can check what the service forwards. Boxes that can run the uplink say so to the app. `POST` and `DELETE /api/cloud/stats` record and withdraw an owner's consent to statistics; nothing is sent for it yet.
10 changes: 7 additions & 3 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ func startAppLink(
siteMeterStale time.Duration,
gateway *lateAPI,
webPush *notifications.WebPush,
cloud *cloudSession,
) (*appenroll.Identity, *appuplink.Uplink, bool, error) {
enabled := cfg != nil && cfg.AppLink.On()

Expand Down Expand Up @@ -610,9 +611,12 @@ func startAppLink(
API: gateway,
Caller: caller,
Grants: grants,
Caps: caps,
Codec: appuplink.Codec(),
Sender: sender,
// FTW Cloud is asked per session: its link is bound after
// this returns, and its grant moves while the box runs.
Caps: cloud.caps(caps),
Cloud: cloud.info,
Codec: appuplink.Codec(),
Sender: sender,
// The three frozen power fields point at the source whose
// freshness governs them. The site meter is the only one the
// box can name without knowing the site's hardware.
Expand Down
37 changes: 37 additions & 0 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"math"
"path/filepath"
"slices"
"sort"
"strconv"
"sync"
Expand Down Expand Up @@ -337,6 +338,42 @@ func startCloudLink(
return link
}

// cloudSession is FTW Cloud as an app session sees it. The app link starts
// first, because the cloud link's roster needs its enrolment, so the link is
// bound here once it exists. A session that opens before then hears no
// cloud.link and no session information, which is what a box without FTW
// Cloud says.
type cloudSession struct {
link atomic.Pointer[cloudlink.Link]
}

func (c *cloudSession) bind(link *cloudlink.Link) { c.link.Store(link) }

func (c *cloudSession) load() *cloudlink.Link {
if c == nil {
return nil
}
return c.link.Load()
}

// caps adds cloud.link to an app session's capabilities when this build can
// run the uplink. A release build names no service and says nothing.
func (c *cloudSession) caps(caps []string) []string {
if link := c.load(); link != nil && link.Available() {
return append(slices.Clip(caps), appproto.CapCloudLink)
}
return caps
}

// info is hello_ok's FTW Cloud session information, nil while FTW Cloud is
// off.
func (c *cloudSession) info() *appproto.CloudSession {
if link := c.load(); link != nil {
return link.SessionInfo()
}
return nil
}

// cloudForAPI hands the link to the API, or nothing: a typed nil in an
// interface would pass the routes' nil check and panic on first use.
func cloudForAPI(link *cloudlink.Link) api.CloudGrant {
Expand Down
81 changes: 81 additions & 0 deletions go/cmd/ftw/cloud_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ package main

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"io"
"log/slog"
"path/filepath"
"slices"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/cloudlink"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

Expand Down Expand Up @@ -77,3 +86,75 @@ func TestCloudLoadpointLeavesCapabilitiesAndNamesBehind(t *testing.T) {
t.Fatalf("plugged loadpoint %+v", lp)
}
}

type quietCloudSite struct{}

func (quietCloudSite) Live() cloudlink.Live {
return cloudlink.Live{Sources: map[string]cloudlink.Source{"p1": {Kind: "driver"}}}
}
func (quietCloudSite) Loadpoints() (cloudwire.LP, bool) { return cloudwire.LP{}, false }
func (quietCloudSite) Prices() (cloudwire.Prices, bool) { return cloudwire.Prices{}, false }

type noPhones struct{}

func (noPhones) Devices() []cloudlink.RosterDevice { return nil }

func newTestCloudLink(t *testing.T, keys []*ecdsa.PublicKey) *cloudlink.Link {
t.Helper()
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
link, err := cloudlink.New(cloudlink.Options{
Site: quietCloudSite{}, Roster: noPhones{}, KV: st,
KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: keys,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
return link
}

// An app session hears cloud.link only from a build that can run the uplink,
// and the session information only while an owner has FTW Cloud on.
func TestAppSessionsHearOfFTWCloudOnlyWhenItCanRun(t *testing.T) {
base := appproto.DefaultCaps()
var none *cloudSession
unbound := &cloudSession{}
release := &cloudSession{}
release.bind(newTestCloudLink(t, []*ecdsa.PublicKey{}))
for name, c := range map[string]*cloudSession{"no link": none, "not bound yet": unbound, "release build": release} {
if slices.Contains(c.caps(base), appproto.CapCloudLink) || c.info() != nil {
t.Fatalf("%s: caps %v, info %+v", name, c.caps(base), c.info())
}
}

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
link := newTestCloudLink(t, []*ecdsa.PublicKey{&key.PublicKey})
withService := &cloudSession{}
withService.bind(link)
caps := withService.caps(base)
if !slices.Contains(caps, appproto.CapCloudLink) || slices.Contains(base, appproto.CapCloudLink) {
t.Fatalf("caps %v from %v", caps, base)
}
if withService.info() != nil {
t.Fatal("session information before a grant")
}
if err := link.Grant(cloudlink.GrantRequest{Scopes: []string{appproto.ScopeLiveRead}, Policy: "2026-10"}); err != nil {
t.Fatal(err)
}
info := withService.info()
if info == nil || info.Home != link.Status().Home || len(info.Sources) != 1 {
t.Fatalf("session information %+v", info)
}
for id, name := range info.Sources {
if name != "p1" || len(id) != 11 {
t.Fatalf("source %q named %q", id, name)
}
}
}
5 changes: 4 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2423,10 +2423,12 @@ func main() {
// The app's window onto this box's own HTTP API. Handed to the uplink
// now and filled in below, once the API server exists.
appAPI := &lateAPI{}
// FTW Cloud as app sessions see it, bound below once the link exists.
appCloud := &cloudSession{}
appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink(
ctx, cfg, identityKeyPath, boxID, Version,
st, tel, mpcSvc, lpMgr, lpController, priceSvc, ctrl, ctrlMu, plannerPrefs,
controlRev, appLinkWatchdog, appAPI, webPush,
controlRev, appLinkWatchdog, appAPI, webPush, appCloud,
)
switch {
case appLinkErr != nil:
Expand All @@ -2443,6 +2445,7 @@ func main() {
cloudNotify := &cloudNotices{}
cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll,
tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify)
appCloud.bind(cloudLink)

// The anonymous fleet ping: how many boxes, which versions, which drivers.
// Its separate relay HTTP door keeps daily totals; the encrypted app path
Expand Down
54 changes: 53 additions & 1 deletion go/internal/api/api_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,26 @@ import (
// over an app session, or on the LAN only from the box itself or with the
// house password. Turning it off narrows access, so any door may do that. The
// box cannot verify the app's step-up, so the grant asks for none and the
// app's consent screen claims no more than an owner's session.
// app's consent screen claims no more than an owner's session. Statistics are
// a second consent under the grant, behind the same doors.

// CloudGrant is the box's FTW Cloud link as the routes see it.
type CloudGrant interface {
Status() cloudlink.Status
Preview() cloudlink.Preview
Grant(cloudlink.GrantRequest) error
Revoke(by string) error
Stats(cloudlink.StatsRequest) error
StopStats(by string) error
}

func (s *Server) cloudRoutes() {
s.handle("GET /api/cloud", Read, s.handleCloudStatus)
s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview)
s.handle("POST /api/cloud/grant", Configure, s.handleCloudGrant, NoStepUp)
s.handle("DELETE /api/cloud/grant", Configure, s.handleCloudRevoke, NoStepUp)
s.handle("POST /api/cloud/stats", Configure, s.handleCloudStats, NoStepUp)
s.handle("DELETE /api/cloud/stats", Configure, s.handleCloudStopStats, NoStepUp)
}

// cloudGate admits an owner's session, and on the LAN a local client. With
Expand Down Expand Up @@ -157,6 +162,53 @@ func (s *Server) handleCloudRevoke(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, link.Status())
}

// POST /api/cloud/stats {policy} — contribute to statistics, a separate
// consent under a grant already on. Same gate as the grant.
func (s *Server) handleCloudStats(w http.ResponseWriter, r *http.Request) {
if !s.cloudGate(w, r, true) {
return
}
link, ok := s.cloudLink(w)
if !ok {
return
}
var req cloudlink.StatsRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeAppLinkError(w, http.StatusBadRequest, "send {policy}")
return
}
req.By = cloudCaller(r)
switch err := link.Stats(req); {
case err == nil:
writeJSON(w, http.StatusOK, link.Status())
case errors.Is(err, cloudlink.ErrGrantRequest):
writeAppLinkError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, cloudlink.ErrNotGranted), errors.Is(err, cloudlink.ErrPurgePending):
writeAppLinkError(w, http.StatusConflict, err.Error())
case errors.Is(err, cloudlink.ErrNoService):
writeAppLinkError(w, http.StatusServiceUnavailable, err.Error())
default:
writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics on")
}
}

// DELETE /api/cloud/stats — stop contributing to statistics. Any door, as for
// the grant.
func (s *Server) handleCloudStopStats(w http.ResponseWriter, r *http.Request) {
if !s.cloudGate(w, r, false) {
return
}
link, ok := s.cloudLink(w)
if !ok {
return
}
if err := link.StopStats(cloudCaller(r)); err != nil {
writeAppLinkError(w, http.StatusInternalServerError, "could not turn statistics off")
return
}
writeJSON(w, http.StatusOK, link.Status())
}

// CloudLoadpoints is the loadpoint state GET /api/loadpoints composes, and
// the control feedback beside it, for the FTW Cloud uplink. ok is false on a
// box without loadpoints.
Expand Down
Loading
Loading