diff --git a/.changeset/command-uplink-cloud.md b/.changeset/command-uplink-cloud.md new file mode 100644 index 000000000..9e47f902f --- /dev/null +++ b/.changeset/command-uplink-cloud.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The app's command uplink can be pointed at FTW Cloud. Leave `app_link.command_endpoint` unset to keep the Frankfurt relay. Set it to `wss://cloud.ftw.energy` to send the same end-to-end encrypted sessions there; if that door does not answer, the box dials the relay. diff --git a/config.example.yaml b/config.example.yaml index a2d2b6437..94ee63dfe 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -212,13 +212,19 @@ state: path: state.db # backup_dir: backups # use an absolute mounted path for off-device backups -# The FTW app. On by default: the box holds an outbound connection to a relay -# that carries encrypted frames and holds no keys. The relay still sees the -# source IP, timing and connection pattern. Set enabled to false to opt out. -# An empty app_link object is also off. Pair a phone by scanning the box's code; -# the pairing code is single-use and expires in ten minutes. +# The FTW app. On by default: the box holds an outbound connection that carries +# end-to-end encrypted frames. The door still sees the source IP, timing and +# connection pattern. Set enabled to false to opt out. An empty app_link object +# is also off. Pair a phone by scanning the box's code; the pairing code is +# single-use and expires in ten minutes. +# +# command_endpoint chooses that door. Omit it, or set the relay, to keep today's +# behavior. wss://cloud.ftw.energy sends the same frames to FTW Cloud on their +# own socket (not the data uplink at /v1/box). If that door does not complete +# the WebSocket handshake, the box dials the relay. Any other origin is refused. app_link: enabled: true + # command_endpoint: wss://cloud.ftw.energy # Anonymous fleet ping. Once a day, at a time that moves, the box tells the # FTW relay six things: its FTW version, its release channel, which driver diff --git a/docs/architecture.md b/docs/architecture.md index ca68dc414..0eaec7799 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -361,10 +361,14 @@ from the same SQLite snapshot so older Core versions also read current settings. ## Remote access boundary -Remote access is the FTW app and nothing else. The box holds one outbound WSS -connection to `wss://relay.ftw.energy`, and beside it a second socket for a -watch's one-shot exchanges; there is no inbound listener, no NAT-traversal -layer, no cloud account and no browser-managed site directory. +Remote access for commands is the FTW app and nothing else. By default the +box holds one outbound WSS connection to `wss://relay.ftw.energy`, and beside +it a second socket for a watch's one-shot exchanges. `app_link.command_endpoint` +may name `wss://cloud.ftw.energy` instead; that door speaks the same frames +on its own outbound socket, and a handshake it does not complete falls back +to the relay. The FTW Cloud data uplink is a separate socket and does not +carry these frames. There is no inbound listener, no NAT-traversal layer +and no browser-managed site directory. See [ADR 0006](adr/0006-app-uplink.md) for why Home Link was removed rather than kept alongside it. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 4cd92892c..0c0558931 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -703,7 +703,15 @@ func startAppLink( } } + // The command door is the relay unless config names the cloud. The data + // uplink is a different socket and cannot carry these frames: its + // downlink is a closed set and it closes on a relay frame. A cloud door + // that does not answer falls back to the relay. Noise still runs between + // the phone and this box, and the box still dials out. + primary, fallback := cfg.AppLink.CommandDoor() uplink, err := appuplink.New(appuplink.Options{ + Endpoint: primary, + Fallback: fallback, Enroll: enroll, Deadman: deadman, Handler: handlerFor(false), diff --git a/go/cmd/ftw/app_link_command_door_test.go b/go/cmd/ftw/app_link_command_door_test.go new file mode 100644 index 000000000..415e0daef --- /dev/null +++ b/go/cmd/ftw/app_link_command_door_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "testing" + + "github.com/srcfl/ftw/go/internal/appuplink" + "github.com/srcfl/ftw/go/internal/config" +) + +// The config allowlist and the uplink's constants are the same two doors. +// A drift here would send commands to a host the other half does not dial. +func TestCommandDoorConstantsMatchTheUplink(t *testing.T) { + if config.CommandRelayOrigin != appuplink.Endpoint { + t.Fatalf("relay %q != uplink %q", config.CommandRelayOrigin, appuplink.Endpoint) + } + if config.CommandCloudOrigin != appuplink.CloudCommandOrigin { + t.Fatalf("cloud %q != uplink %q", config.CommandCloudOrigin, appuplink.CloudCommandOrigin) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index da5997e12..dce54beb2 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -37,7 +37,6 @@ import ( _ "time/tzdata" "github.com/srcfl/ftw/go/internal/api" - "github.com/srcfl/ftw/go/internal/appuplink" "github.com/srcfl/ftw/go/internal/arp" "github.com/srcfl/ftw/go/internal/battery" "github.com/srcfl/ftw/go/internal/components" @@ -2435,8 +2434,14 @@ func main() { case appLinkErr != nil: slog.Warn("app link unavailable", "err", appLinkErr) case appLinkEnabled: - slog.Info("app link connected", "relay", appuplink.Endpoint, - "paired_devices", appEnroll.AuthorisedCount()) + commandDoor, commandFallback := cfg.AppLink.CommandDoor() + if commandFallback == "" { + slog.Info("app link connected", "relay", commandDoor, + "paired_devices", appEnroll.AuthorisedCount()) + } else { + slog.Info("app link connected", "command", commandDoor, "fallback", commandFallback, + "paired_devices", appEnroll.AuthorisedCount()) + } default: slog.Info("app link disabled — set app_link.enabled to turn it on") } diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index cab90f236..f498dd930 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -1,12 +1,19 @@ -// Package appuplink keeps the box's outbound connection to the content-blind -// relay, and runs the app sessions that arrive over it. +// Package appuplink keeps the box's outbound command connection and runs the +// app sessions that arrive over it. // -// The relay is the only remote path between a box and a phone, and it cannot -// read a watt: it joins one box uplink to a handful of browser streams under a -// rendezvous handle and moves opaque bytes between them. Everything that makes -// those bytes meaningful is in go/protocol/appwire (the Noise session) and -// go/protocol (the messages). See srcfl/ftw-webapp relay/README.md -// for the server's half. +// The default door is the content-blind Frankfurt relay. Config may point the +// same frames at FTW Cloud; a cloud that does not answer the dial falls back +// to the relay. Either door joins one box uplink to a handful of browser +// streams under a rendezvous handle and moves opaque bytes between them. It +// cannot read a watt. Everything that makes those bytes meaningful is in +// go/protocol/appwire (the Noise session) and go/protocol (the messages). See +// srcfl/ftw-webapp relay/README.md for the relay's half. FTW Cloud, when it +// is the door, speaks that same frame format on its own socket. +// +// That socket is not the data uplink. cloudlink dials +// wss://cloud.ftw.energy/v1/box, whose downlink is a closed set of five +// message types and which closes on a text frame or a relay frame. Command +// sessions stay end-to-end Noise on a second outbound socket. // // One rule shapes the whole file: a dropped connection is this package's // problem. There is no reconnect button in the app and no place to put one, so @@ -37,12 +44,19 @@ import ( "github.com/srcfl/ftw/go/internal/appproto" ) -// Endpoint is the production relay. Not configurable: a box that can be -// pointed at another relay is a box a support call can point at an attacker's, -// and the relay is fixed by the protocol, so there is nothing to gain by -// choosing one. +// Endpoint is the Frankfurt relay, and the command door when none is +// configured. Config may name CloudCommandOrigin instead; any other origin +// is refused there, so a support call cannot point the box at an attacker's +// relay. This package still accepts a ws origin so a test can dial a local +// door. A configured cloud door that does not complete the WebSocket +// handshake falls back to this relay. const Endpoint = "wss://relay.ftw.energy" +// CloudCommandOrigin is FTW Cloud's command door. It is an origin, with no +// path: the box appends /r/{epoch}/{handle}/box the same way it does for the +// relay. It is not the data uplink at /v1/box. +const CloudCommandOrigin = "wss://cloud.ftw.energy" + // The relay's close codes and control words. Held here rather than shared with // the server so the box builds without it; relay_contract_test.go fails if // they drift from srcfl/ftw-webapp relay/src/protocol.ts. @@ -116,9 +130,15 @@ const ( // Options wires the uplink. Everything except the injectable seams is required. type Options struct { - // Endpoint is the relay origin, no path. Defaults to Endpoint. + // Endpoint is the command-door origin, no path. Empty means Endpoint, + // the Frankfurt relay. Endpoint string + // Fallback is dialed when Endpoint does not complete the WebSocket + // handshake. Empty means there is no second door, which is the default. + // Production sets this to the relay when Endpoint is the cloud. + Fallback string + // Enroll owns the static key, the rendezvous secret and the record of // which phones are allowed in. Enroll *appenroll.Identity @@ -233,18 +253,19 @@ func New(opts Options) (*Uplink, error) { return nil, errors.New("appuplink: Handler is required") } - endpoint := opts.Endpoint - if endpoint == "" { - endpoint = Endpoint + endpoint, err := parseOrigin(opts.Endpoint, Endpoint) + if err != nil { + return nil, err } - parsed, err := url.Parse(endpoint) - if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" { - return nil, fmt.Errorf("appuplink: %q is not a relay origin", endpoint) + fallback, err := parseOrigin(opts.Fallback, "") + if err != nil { + return nil, err } - if parsed.Scheme != "wss" && parsed.Scheme != "ws" { - return nil, fmt.Errorf("appuplink: %q is not a WebSocket scheme", parsed.Scheme) + if fallback == endpoint { + fallback = "" } - opts.Endpoint = strings.TrimRight(endpoint, "/") + opts.Endpoint = endpoint + opts.Fallback = fallback if opts.Logger == nil { opts.Logger = slog.Default() @@ -292,6 +313,25 @@ func New(opts Options) (*Uplink, error) { return u, nil } +// parseOrigin checks a command-door origin. defaultOrigin is used when +// endpoint is empty; an empty default means the field is optional. +func parseOrigin(endpoint, defaultOrigin string) (string, error) { + if endpoint == "" { + endpoint = defaultOrigin + } + if endpoint == "" { + return "", nil + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil { + return "", fmt.Errorf("appuplink: %q is not a command-door origin", endpoint) + } + if parsed.Scheme != "wss" && parsed.Scheme != "ws" { + return "", fmt.Errorf("appuplink: %q is not a WebSocket scheme", parsed.Scheme) + } + return strings.TrimRight(endpoint, "/"), nil +} + func productionDialer() *websocket.Dialer { return &websocket.Dialer{ Proxy: http.ProxyFromEnvironment, @@ -340,16 +380,16 @@ func (u *Uplink) once(ctx context.Context) (time.Duration, error) { return backoffCap, err } - conn, err := u.dial(ctx, epoch, handle) + conn, origin, err := u.dial(ctx, epoch, handle) if err != nil { - return u.backoff(), fmt.Errorf("appuplink: dialling the relay: %w", err) + return u.backoff(), fmt.Errorf("appuplink: dialling the command uplink: %w", err) } // A connection that lasted is not a connection that is failing. Without // this, an hourly rotation would compound the backoff until a box that // works perfectly waits a minute after every rotation. connectedAt := u.now() - end := u.ride(ctx, u.start(ctx, conn, epoch, handle)) + end := u.ride(ctx, u.start(ctx, conn, epoch, handle, origin)) if u.now().Sub(connectedAt) >= backoffCap { u.attempt = 0 } @@ -381,11 +421,38 @@ func (u *Uplink) epoch() int64 { return CurrentEpoch(u.now().UnixMilli()) + u.epochOffset } -// dial opens a socket under one epoch's handle. The relay answers a wrong +// dial opens a socket under one epoch's handle. The door answers a wrong // epoch after the upgrade, with a close, so an open socket is not yet an // accepted one. -func (u *Uplink) dial(ctx context.Context, epoch int64, handle string) (*websocket.Conn, error) { - target := fmt.Sprintf("%s/r/%d/%s/box", u.opts.Endpoint, epoch, handle) +// +// When a fallback is configured, a handshake that does not complete is +// tried again on the relay. A socket that opens and then closes is not a +// fallback: the door accepted the box, and the usual retry handles the rest. +// The returned origin is the door that accepted the socket. The exchange +// socket and the dead-man rows stay on that door, so a phone and a watch +// meet in the same room. +func (u *Uplink) dial(ctx context.Context, epoch int64, handle string) (*websocket.Conn, string, error) { + origin := u.opts.Endpoint + conn, err := u.dialOrigin(ctx, origin, epoch, handle) + if err == nil { + u.setCommandOrigin(origin) + return conn, origin, nil + } + if u.opts.Fallback == "" { + return nil, "", err + } + u.log.Warn("command uplink unreachable; falling back to the relay", + "endpoint", origin, "fallback", u.opts.Fallback, "err", err) + conn, ferr := u.dialOrigin(ctx, u.opts.Fallback, epoch, handle) + if ferr != nil { + return nil, "", fmt.Errorf("%w; relay fallback: %v", err, ferr) + } + u.setCommandOrigin(u.opts.Fallback) + return conn, u.opts.Fallback, nil +} + +func (u *Uplink) dialOrigin(ctx context.Context, origin string, epoch int64, handle string) (*websocket.Conn, error) { + target := fmt.Sprintf("%s/r/%d/%s/box", origin, epoch, handle) dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) defer cancel() @@ -411,15 +478,16 @@ type linkEnd struct { err error } -func (u *Uplink) start(ctx context.Context, conn *websocket.Conn, epoch int64, handle string) *link { +func (u *Uplink) start(ctx context.Context, conn *websocket.Conn, epoch int64, handle, origin string) *link { ctx, stop := context.WithCancel(ctx) l := &link{epoch: epoch, stop: stop, done: make(chan linkEnd, 1)} go func() { defer stop() // The exchange socket rides beside this connection, under the same - // epoch and handle, and ends with it. + // epoch, handle and door, and ends with it. A watch that dialled the + // other door would sit in an empty room. if u.exchanges != nil { - go u.keepExchange(ctx, epoch, handle) + go u.keepExchange(ctx, origin, epoch, handle) } code, reason, err := u.serve(ctx, conn) l.done <- linkEnd{code: code, reason: reason, err: err} @@ -528,12 +596,12 @@ func (u *Uplink) join(ctx context.Context, epoch int64) *link { if err != nil { return nil } - conn, err := u.dial(ctx, epoch, handle) + conn, origin, err := u.dial(ctx, epoch, handle) if err != nil { u.log.Debug("could not join the new hour's handle yet", "err", err) return nil } - return u.start(ctx, conn, epoch, handle) + return u.start(ctx, conn, epoch, handle, origin) } // serve runs one connection. It returns the close code and reason when the diff --git a/go/internal/appuplink/command_door_test.go b/go/internal/appuplink/command_door_test.go new file mode 100644 index 000000000..be5e98fb4 --- /dev/null +++ b/go/internal/appuplink/command_door_test.go @@ -0,0 +1,166 @@ +package appuplink + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/appenroll" +) + +func TestDialPrefersTheConfiguredDoor(t *testing.T) { + cloud := newFakeRelay(481234) + defer cloud.close() + var fallbackHits atomic.Int32 + fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fallbackHits.Add(1) + http.Error(w, "relay should not be dialed", http.StatusBadGateway) + })) + defer fallback.Close() + + u := newCommandDoor(t, cloud.url(), wsOrigin(fallback.URL)) + conn, origin, err := u.dial(context.Background(), 481234, "abc") + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + if origin != cloud.url() { + t.Fatalf("origin = %q, want %q", origin, cloud.url()) + } + if fallbackHits.Load() != 0 { + t.Fatalf("fallback was dialed %d times", fallbackHits.Load()) + } + if got := u.commandHTTPOrigin(); got != deadmanOrigin(cloud.url()) { + t.Fatalf("deadman origin = %q, want %q", got, deadmanOrigin(cloud.url())) + } +} + +func TestDialFallsBackWhenTheCloudRefuses(t *testing.T) { + var cloudHits atomic.Int32 + cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cloudHits.Add(1) + http.Error(w, "command door not ready", http.StatusServiceUnavailable) + })) + defer cloud.Close() + relay := newFakeRelay(481234) + defer relay.close() + + u := newCommandDoor(t, wsOrigin(cloud.URL), relay.url()) + conn, origin, err := u.dial(context.Background(), 481234, "abc") + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + if origin != relay.url() { + t.Fatalf("origin = %q, want the relay %q", origin, relay.url()) + } + if cloudHits.Load() != 1 { + t.Fatalf("cloud saw %d dials, want 1", cloudHits.Load()) + } + if got := u.commandHTTPOrigin(); got != deadmanOrigin(relay.url()) { + t.Fatalf("deadman origin = %q, want %q", got, deadmanOrigin(relay.url())) + } +} + +func TestDialReportsBothDoorsWhenNeitherAnswers(t *testing.T) { + refuse := func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "down", http.StatusServiceUnavailable) + } + cloud := httptest.NewServer(http.HandlerFunc(refuse)) + defer cloud.Close() + relay := httptest.NewServer(http.HandlerFunc(refuse)) + defer relay.Close() + + u := newCommandDoor(t, wsOrigin(cloud.URL), wsOrigin(relay.URL)) + if _, _, err := u.dial(context.Background(), 481234, "abc"); err == nil { + t.Fatal("dial succeeded with both doors down") + } +} + +func TestExchangeStaysOnTheDoorThatAcceptedTheBox(t *testing.T) { + var cloudHits atomic.Int32 + cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cloudHits.Add(1) + http.Error(w, "command door not ready", http.StatusServiceUnavailable) + })) + defer cloud.Close() + relay := newFakeRelay(481234) + defer relay.close() + + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + now := time.UnixMilli(481234 * EpochMs) + u, err := New(Options{ + Endpoint: wsOrigin(cloud.URL), + Fallback: relay.url(), + Enroll: enroll, + Handler: newHandler, + ExchangeHandler: newHandler, + Logger: slog.New(slog.DiscardHandler), + Now: func() time.Time { return now }, + Random: func() float64 { return 0 }, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = u.Run(ctx) }() + + select { + case <-relay.exchangeJoined: + case <-time.After(5 * time.Second): + t.Fatal("exchange did not join the relay") + } + time.Sleep(50 * time.Millisecond) + if got := cloudHits.Load(); got != 1 { + t.Fatalf("cloud saw %d dials, want 1 (the refused box socket)", got) + } +} + +func TestAFallbackWithAPathIsRefused(t *testing.T) { + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + _, err = New(Options{ + Endpoint: CloudCommandOrigin, + Fallback: "wss://relay.ftw.energy/v1/box", + Enroll: enroll, + Handler: newHandler, + }) + if err == nil { + t.Fatal("a fallback with a path was accepted") + } +} + +func newCommandDoor(t *testing.T, endpoint, fallback string) *Uplink { + t.Helper() + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + u, err := New(Options{ + Endpoint: endpoint, + Fallback: fallback, + Enroll: enroll, + Handler: newHandler, + Logger: slog.New(slog.DiscardHandler), + }) + if err != nil { + t.Fatal(err) + } + return u +} + +func wsOrigin(httpURL string) string { + return "ws" + strings.TrimPrefix(httpURL, "http") +} diff --git a/go/internal/appuplink/deadman.go b/go/internal/appuplink/deadman.go index 9e2eca608..86388ad35 100644 --- a/go/internal/appuplink/deadman.go +++ b/go/internal/appuplink/deadman.go @@ -96,7 +96,22 @@ func DeadmanID(secret []byte, subscriptionID string) string { return hex.EncodeToString(mac.Sum(nil)[:16]) } -// deadmanOrigin is the relay's HTTPS side of the same host the uplink dials. +// setCommandOrigin records the door that accepted the latest socket, so +// dead-man rows are posted to that door and not to one the box failed to +// reach. +func (u *Uplink) setCommandOrigin(origin string) { + u.deadmanMu.Lock() + u.deadmanOrigin = deadmanOrigin(origin) + u.deadmanMu.Unlock() +} + +func (u *Uplink) commandHTTPOrigin() string { + u.deadmanMu.Lock() + defer u.deadmanMu.Unlock() + return u.deadmanOrigin +} + +// deadmanOrigin is the HTTPS side of the same host the command socket dials. func deadmanOrigin(endpoint string) string { switch { case strings.HasPrefix(endpoint, "wss://"): @@ -195,7 +210,7 @@ func (u *Uplink) postDeadman(ctx context.Context, id string, row DeadmanRow) err return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, - u.deadmanOrigin+"/deadman", bytes.NewReader(body)) + u.commandHTTPOrigin()+"/deadman", bytes.NewReader(body)) if err != nil { return err } @@ -205,7 +220,7 @@ func (u *Uplink) postDeadman(ctx context.Context, id string, row DeadmanRow) err func (u *Uplink) deleteDeadman(ctx context.Context, id string) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, - u.deadmanOrigin+"/deadman/"+id, nil) + u.commandHTTPOrigin()+"/deadman/"+id, nil) if err != nil { return err } diff --git a/go/internal/appuplink/exchange.go b/go/internal/appuplink/exchange.go index c2ceb903d..f21c6455e 100644 --- a/go/internal/appuplink/exchange.go +++ b/go/internal/appuplink/exchange.go @@ -462,10 +462,10 @@ func (x *exchanges) count() int { // handle, and reconnects it with its own backoff until the connection ends. A // relay that refuses the role, or a socket that drops, costs the watch its // door and nothing else. -func (u *Uplink) keepExchange(ctx context.Context, epoch int64, handle string) { +func (u *Uplink) keepExchange(ctx context.Context, origin string, epoch int64, handle string) { attempt := 0 for ctx.Err() == nil { - target := fmt.Sprintf("%s/r/%d/%s/exchange", u.opts.Endpoint, epoch, handle) + target := fmt.Sprintf("%s/r/%d/%s/exchange", origin, epoch, handle) dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) conn, response, err := u.dialer.DialContext(dialCtx, target, nil) cancel() diff --git a/go/internal/config/config.go b/go/internal/config/config.go index bfdf98bc8..56e93f0f4 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -211,12 +211,29 @@ func (o *OCPP) ChargerSecrets() map[string]string { return out } +// CommandRelayOrigin is the Frankfurt relay. Commands go here unless +// CommandEndpoint names the cloud. +const CommandRelayOrigin = "wss://relay.ftw.energy" + +// CommandCloudOrigin is FTW Cloud's command door. The box dials it outbound +// and speaks the relay frame format there; Noise still ends at the phone. +// It is not the data uplink (wss://cloud.ftw.energy/v1/box). That socket's +// downlink is a closed set and closes on a relay frame, so the two are not +// multiplexed. +const CommandCloudOrigin = "wss://cloud.ftw.energy" + // AppLink controls the outbound connection the FTW app reaches this box // through. It defaults on when the section is absent; an explicit false is the -// opt-out. One switch is enough because the relay is content-blind and fixed -// by the protocol, so there is no endpoint or transport to choose. +// opt-out. The command door defaults to the Frankfurt relay. Naming the cloud +// is the transition off that relay; a door that does not answer falls back. type AppLink struct { Enabled bool `yaml:"enabled" json:"enabled"` + // CommandEndpoint is where encrypted app sessions go. Empty keeps the + // Frankfurt relay. The only other accepted value is the cloud origin + // above: a support call must not be able to point the box at an + // attacker's relay. The address is not written in when the section is + // filled in, so a household that never set it keeps following the default. + CommandEndpoint string `yaml:"command_endpoint,omitempty" json:"command_endpoint,omitempty"` } // On is the single reading of the app-link switch. A nil section means an old @@ -229,6 +246,48 @@ func (a *AppLink) On() bool { return a.Enabled } +// CommandDoor is the origin the command uplink dials, and the relay to try +// when that origin does not complete a WebSocket handshake. An empty or +// relay endpoint has no fallback, which is today's behavior. The cloud door +// falls back to the relay for the transition. A value Validate refused still +// resolves to the relay, so a skipped check cannot steer the box elsewhere. +func (a *AppLink) CommandDoor() (primary, fallback string) { + ep := "" + if a != nil { + ep = strings.TrimRight(strings.TrimSpace(a.CommandEndpoint), "/") + } + switch ep { + case "", CommandRelayOrigin: + return CommandRelayOrigin, "" + case CommandCloudOrigin: + return CommandCloudOrigin, CommandRelayOrigin + default: + return CommandRelayOrigin, "" + } +} + +// Validate rejects a command door that is not the relay or the cloud. Nil +// and empty are the relay, and they are valid. +func (a *AppLink) Validate() error { + if a == nil { + return nil + } + raw := strings.TrimSpace(a.CommandEndpoint) + if raw == "" { + return nil + } + ep := strings.TrimRight(raw, "/") + switch ep { + case CommandRelayOrigin, CommandCloudOrigin: + return nil + } + parsed, err := url.Parse(ep) + if err == nil && parsed.Hostname() == "cloud.ftw.energy" && parsed.Path != "" && parsed.Path != "/" { + return errors.New("app_link.command_endpoint is the command door wss://cloud.ftw.energy, not the data uplink") + } + return fmt.Errorf("app_link.command_endpoint must be %s or %s", CommandRelayOrigin, CommandCloudOrigin) +} + // FleetPing configures the once-a-day count of how many boxes run FTW, on // which version and with which drivers. On by default, because the numbers // are what decide where engineering effort goes and a fleet nobody can see is @@ -1931,6 +1990,9 @@ func (c *Config) Validate() error { if err := c.FleetPing.Validate(); err != nil { return err } + if err := c.AppLink.Validate(); err != nil { + return err + } if err := c.validateModbusProxy(); err != nil { return err } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 86bb85cb9..4c6c6b197 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -980,6 +980,74 @@ func TestSlewExplicitDisablePreserved(t *testing.T) { } } +func TestCommandDoorDefaultsToTheRelay(t *testing.T) { + primary, fallback := (*AppLink)(nil).CommandDoor() + if primary != CommandRelayOrigin || fallback != "" { + t.Fatalf("nil door = %q fallback %q", primary, fallback) + } + primary, fallback = (&AppLink{Enabled: true}).CommandDoor() + if primary != CommandRelayOrigin || fallback != "" { + t.Fatalf("empty endpoint = %q fallback %q", primary, fallback) + } + primary, fallback = (&AppLink{Enabled: true, CommandEndpoint: CommandRelayOrigin}).CommandDoor() + if primary != CommandRelayOrigin || fallback != "" { + t.Fatalf("explicit relay = %q fallback %q", primary, fallback) + } +} + +func TestCommandDoorPointsAtTheCloudAndFallsBack(t *testing.T) { + for _, ep := range []string{CommandCloudOrigin, CommandCloudOrigin + "/"} { + primary, fallback := (&AppLink{Enabled: true, CommandEndpoint: ep}).CommandDoor() + if primary != CommandCloudOrigin || fallback != CommandRelayOrigin { + t.Fatalf("endpoint %q door = %q fallback %q", ep, primary, fallback) + } + } +} + +func TestCommandEndpointRejectsAnythingButTheRelayOrTheCloud(t *testing.T) { + cases := []struct { + endpoint string + dataPath bool + }{ + {endpoint: "wss://evil.example"}, + {endpoint: "ws://cloud.ftw.energy"}, + {endpoint: "https://cloud.ftw.energy"}, + {endpoint: "wss://cloud.ftw.energy/v1/box", dataPath: true}, + {endpoint: "wss://user:pass@cloud.ftw.energy"}, + {endpoint: "wss://relay.ftw.energy/r/1/abc/box"}, + } + for _, tc := range cases { + cfg, err := Parse([]byte(minimalYAML+"app_link:\n enabled: true\n command_endpoint: \""+tc.endpoint+"\"\n"), t.TempDir()) + if err == nil { + t.Fatalf("%q was accepted, door %+v", tc.endpoint, cfg.AppLink) + } + if tc.dataPath && !strings.Contains(err.Error(), "data uplink") { + t.Fatalf("%q error %v, want the data-uplink refusal", tc.endpoint, err) + } + } +} + +func TestCommandEndpointRoundTripsWithoutBecomingTheDefault(t *testing.T) { + cfg, err := Parse([]byte(minimalYAML+"app_link:\n enabled: true\n command_endpoint: \""+CommandCloudOrigin+"\"\n"), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg.AppLink.CommandEndpoint != CommandCloudOrigin { + t.Fatalf("stored endpoint %q", cfg.AppLink.CommandEndpoint) + } + omitted, err := Parse([]byte(minimalYAML), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if omitted.AppLink.CommandEndpoint != "" { + t.Fatalf("omitted endpoint was written as %q", omitted.AppLink.CommandEndpoint) + } + primary, fallback := omitted.AppLink.CommandDoor() + if primary != CommandRelayOrigin || fallback != "" { + t.Fatalf("omitted door = %q fallback %q", primary, fallback) + } +} + func TestAppLinkDefaultsOnAndPreservesOptOut(t *testing.T) { absent := &Config{} applyDefaults(absent) diff --git a/go/internal/config/restart_required.go b/go/internal/config/restart_required.go index 4d9a623dd..eba5a7107 100644 --- a/go/internal/config/restart_required.go +++ b/go/internal/config/restart_required.go @@ -41,6 +41,11 @@ func RestartRequiredFor(oldCfg, newCfg *Config) []string { if oldCfg.AppLink.On() != newCfg.AppLink.On() { reasons = append(reasons, "app_link — the app uplink is connected at startup") } + oldDoor, oldFallback := oldCfg.AppLink.CommandDoor() + newDoor, newFallback := newCfg.AppLink.CommandDoor() + if oldDoor != newDoor || oldFallback != newFallback { + reasons = append(reasons, "app_link.command_endpoint — the command uplink is dialed at startup") + } if oldCfg.FleetPing.Resolved() != newCfg.FleetPing.Resolved() { reasons = append(reasons, "fleet_ping.endpoint — the sender resolves its endpoint at startup") } diff --git a/go/internal/config/restart_required_test.go b/go/internal/config/restart_required_test.go index 6fceb818b..229e6a3b0 100644 --- a/go/internal/config/restart_required_test.go +++ b/go/internal/config/restart_required_test.go @@ -72,6 +72,9 @@ func TestRestartRequiredFor_BootSections(t *testing.T) { {"planner toggled", func(c *Config) { c.Planner = &Planner{Enabled: true} }, "planner"}, {"nova toggled", func(c *Config) { c.Nova = &Nova{Enabled: true, URL: "https://x"} }, "nova"}, {"app link toggled off", func(c *Config) { c.AppLink = &AppLink{Enabled: false} }, "app_link"}, + {"command door moved to the cloud", func(c *Config) { + c.AppLink = &AppLink{Enabled: true, CommandEndpoint: CommandCloudOrigin} + }, "command_endpoint"}, {"ev_charger added", func(c *Config) { c.EVCharger = &EVCharger{Provider: "easee", Username: "a@b.c"} }, "ev_charger"},