Skip to content
Draft
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/command-uplink-cloud.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 11 additions & 5 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
19 changes: 19 additions & 0 deletions go/cmd/ftw/app_link_command_door_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 8 additions & 3 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
Expand Down
134 changes: 101 additions & 33 deletions go/internal/appuplink/client.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -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}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading