diff --git a/.changeset/app-uplink-and-enrollment.md b/.changeset/app-uplink-and-enrollment.md index fbc781f92..2ee8157b7 100644 --- a/.changeset/app-uplink-and-enrollment.md +++ b/.changeset/app-uplink-and-enrollment.md @@ -2,8 +2,10 @@ "ftw": minor --- -The box now speaks the FTW app's protocol for real. It mints its own Noise static key, a long-lived rendezvous secret and single-use pairing codes, prints them as the QR payload the app parses, and holds an outbound connection to the blind relay under a handle that changes every hour — derived from the secret, so the relay cannot follow a household from one epoch to the next. Up to four phones share the uplink; each gets its own encrypted session, and the box tells them apart by which one's key authenticates a frame, because the relay cannot say. A phone is let in by a pairing code once and by its own key thereafter, so reconnecting never needs a new code and a photographed code cannot pair a second device. +The box now speaks the FTW app's protocol for real. It mints its own Noise static key, a long-lived rendezvous secret and single-use pairing codes, prints them as the QR payload the app parses, and holds an outbound connection to the content-blind relay under a handle that changes every hour. The rotating handle removes a stable protocol ID; it does not hide the source IP, timing or connection pattern, which the relay can use to correlate a household. Up to four phones share the uplink; each gets its own encrypted session, and the box tells them apart by which one's key authenticates a frame, because the relay cannot say. A phone is let in by a pairing code once and by its own key thereafter, so reconnecting never needs a new code and a photographed code cannot pair a second device. -Turn it on with `app_link.enabled: true`. +The app link is on when an existing config omits the `app_link` section, so +upgraded stable boxes join the supported relay without a manual YAML edit. +`app_link.enabled: false` remains an explicit opt-out. `controlRev` now means something: it is a fingerprint of the site's controllable state, so it moves when the mode or a target does and holds still through the per-tick churn. A command that expected an older revision is refused, and a session that falls behind is resynced with a fresh snapshot rather than left refusing every command until it reconnects. Site mode changes from every door now go through one function, so none can arrive having set the mode without dropping the manual hold and resetting the PI integrator. diff --git a/.changeset/remove-home-link.md b/.changeset/remove-home-link.md index 06cc023ff..75736834f 100644 --- a/.changeset/remove-home-link.md +++ b/.changeset/remove-home-link.md @@ -19,9 +19,10 @@ so no server ever sees it, and afterwards the phone reconnects on its own key a photographed code cannot pair a second phone. You can remove one paired phone at a time from the same tab; removal drops its live session at once. The relay holds no keys and cannot read a frame, and the name the box joins under changes -every hour, so the relay operator cannot tell which household is online or -follow one across the day. Nothing about local control, setup, history or -fallback planning changes. +every hour, so the protocol carries no stable household ID. The relay still +sees source IP, timing and connection continuity, which can correlate a +household across those rotations. Nothing about local control, setup, history +or fallback planning changes. An existing `home_link:` block in your `config.yaml` is ignored — the box boots without you editing anything. Home Link's rows in `state.db` are left where diff --git a/config.example.yaml b/config.example.yaml index 289c50035..9cee9ebd0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -169,12 +169,13 @@ state: path: state.db # backup_dir: backups # use an absolute mounted path for off-device backups -# The FTW app. Opt-in, and one switch: the box holds an outbound connection to -# a relay that carries encrypted frames and holds no keys, so there is no -# endpoint to choose and no transport to pick. 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 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. app_link: - enabled: false + enabled: true # 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/adr/0006-app-uplink.md b/docs/adr/0006-app-uplink.md index 44808caa2..4c8d0e846 100644 --- a/docs/adr/0006-app-uplink.md +++ b/docs/adr/0006-app-uplink.md @@ -56,15 +56,17 @@ two pairing stories and two things to get right at 1 Hz, for one product. commands, not reading. 3. **The relay is `wss://relay.ftw.energy` and holds no keys.** It forwards - encrypted frames and cannot read or attribute them. Up to four sessions - share one uplink; the relay broadcasts, and the box lets the AEAD decide - which session a frame came from, because asking the relay would mean the - relay had to know. + encrypted frames and cannot read their contents or attribute them from a + protocol identity. It still sees source IPs, timing and connection + continuity. Up to four sessions share one uplink; the relay broadcasts, and + the box lets the AEAD decide which session a frame came from, because asking + the relay would mean the relay had to know. 4. **The box's name on the relay rotates hourly.** The join handle is derived - per epoch from the rendezvous secret with HKDF-SHA256, so the relay operator - cannot follow one household from hour to hour. There is no DNS alias, no - three-word host, and nothing stable for the relay to key a household on. + per epoch from the rendezvous secret with HKDF-SHA256. This removes the + stable protocol handle and DNS alias. It does not stop the relay operator + from correlating a continuous or promptly reconnected socket through its IP + and timing metadata. 5. **Authority is unchanged, which is the point.** A command carries an expiry and preconditions; core revalidates against fresh state before acting. Site @@ -86,12 +88,11 @@ Home Link working: - **`GET /api/home-link/status`, `POST /api/home-link/pairing` and `POST /api/home-link/passkeys/revoke` are gone**, along with the LAN UI's Remote tab. -- **There is no pairing surface on the box's own UI yet.** The QR payload is - minted at boot but nothing renders it. Until that lands, a site that turns on - `app_link` cannot pair a phone from the local UI. -- **Revocation is coarser.** Home Link could revoke one passkey and leave the - others. Today the box can rotate the rendezvous secret, which moves every - paired phone at once. Per-device revocation is owed. +- **Pairing moves to the app tab.** The local UI mints a short-lived owner code + for the first phone and viewer codes for sharing. It lists paired phones and + can remove one without moving the others. +- **The last owner cannot remove or demote itself.** A second owner must exist + first, so a remote role change cannot leave the home without an owner. - **WebAuthn as an on-box capability is gone**, including the signature-counter clone check and the emergency fail-closed markers beside `state.db`. If a future feature needs a local relying party, it starts from nothing. @@ -122,8 +123,9 @@ Home Link working: this. A box with `app_link.enabled: false` behaves exactly as before. - One remote path means one thing to threat-model, one pairing story and one place freshness can be got wrong. -- The relay is now a dumb frame forwarder. It can deny service and it can count - connections per handle for an hour. It cannot identify a household, read a - reading, or name a device. -- Per-device revocation and an on-box pairing surface are open work, and the - app uplink is not finished until they land. +- The relay is now a frame forwarder. It can deny service, count connections + per handle for an hour and observe IP, timing and connection metadata. It + cannot read a reading, name a device from the protocol or decrypt a frame. +- The app link defaults on when an existing config omits the section. An + explicit `app_link.enabled: false` keeps the outbound connection off. An + empty `app_link: {}` section also reads as off. diff --git a/docs/architecture.md b/docs/architecture.md index 243c97005..fba7b33af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -188,7 +188,9 @@ The properties that matter: and later ones by the app's pinned static key; - the relay forwards encrypted frames and holds no keys. The handle the box joins under is derived per epoch from the rendezvous secret, so it rotates - hourly and gives the relay operator nothing stable to key a household on; + hourly and leaves no stable household ID in the protocol. The relay still + sees source IP, timing and connection continuity, which can correlate a + household across handle rotations; - the machine identity in [`go/internal/gatewayidentity`](../go/internal/gatewayidentity) is separate and does not authenticate this connection. It resolves to a hardware-protected P-256 key where the hardware exists and a bound software diff --git a/docs/roadmap.md b/docs/roadmap.md index 46ff7826d..fd61051fe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -76,8 +76,9 @@ planning, and does not. The box holds one outbound WSS connection to `wss://relay.ftw.energy`, joining under a handle derived per epoch from the rendezvous secret with HKDF-SHA256. -The handle rotates hourly, so the relay operator cannot follow a household from -one hour to the next, and there is no DNS alias or other stable per-box name. +The handle rotates hourly, so the protocol exposes no stable per-box name or +DNS alias. The relay still sees source IP, timing and connection continuity, +which can correlate a household across the hour boundary. An epoch correction from the relay is read as a clock correction and clamped; it is never an order. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 717ca3f0e..112859592 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -438,7 +438,8 @@ func (a *appPlans) CeilingW() *int64 { return &ceiling } -// startAppLink connects the box to the app relay, if the site asked for it. +// startAppLink connects the box to the app relay when its loaded config leaves +// the link enabled. Config defaults an omitted app_link section on. // // It returns the enrollment identity whether or not the uplink runs, because // the QR code on the lid is minted from it and a box with the link switched @@ -462,7 +463,7 @@ func startAppLink( gateway *lateAPI, webPush *notifications.WebPush, ) (*appenroll.Identity, *appuplink.Uplink, bool, error) { - enabled := cfg != nil && cfg.AppLink != nil && cfg.AppLink.Enabled + enabled := cfg != nil && cfg.AppLink.On() enroll, err := appenroll.LoadOrCreate(identityKeyPath) if err != nil { diff --git a/go/cmd/ftw/app_link_config_test.go b/go/cmd/ftw/app_link_config_test.go new file mode 100644 index 000000000..4af316a65 --- /dev/null +++ b/go/cmd/ftw/app_link_config_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" +) + +// An upgraded config starts without app_link. The settings round trip must +// persist the default before restart instead of silently turning the relay off. +func TestAppLinkDefaultSurvivesSettingsSaveAndRestart(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + oldYAML := []byte("site:\n name: Test\nfuse:\n max_amps: 16\n phases: 3\n voltage: 230\napi:\n port: 8080\ndrivers: []\n") + if err := os.WriteFile(configPath, oldYAML, 0o600); err != nil { + t.Fatalf("write old config: %v", err) + } + + cfg, err := config.Load(configPath) + if err != nil { + t.Fatalf("load old config: %v", err) + } + if !cfg.AppLink.On() { + t.Fatal("old config did not default the app link on") + } + + var cfgMu sync.RWMutex + var ctrlMu sync.Mutex + srv := api.New(&api.Deps{ + Ctrl: control.NewState(0, 42, ""), CtrlMu: &ctrlMu, + Cfg: cfg, CfgMu: &cfgMu, + ConfigPath: configPath, + DriverDir: dir, UserDriverDir: dir, + SaveConfig: config.SaveAtomic, + }) + + get := httptest.NewRequest(http.MethodGet, "/api/config", nil) + getRR := httptest.NewRecorder() + srv.Handler().ServeHTTP(getRR, get) + if getRR.Code != http.StatusOK { + t.Fatalf("GET /api/config = %d: %s", getRR.Code, getRR.Body.String()) + } + var shown config.Config + if err := json.Unmarshal(getRR.Body.Bytes(), &shown); err != nil { + t.Fatalf("decode GET /api/config: %v", err) + } + if !shown.AppLink.On() { + t.Fatal("GET /api/config showed the defaulted app link as off") + } + + post := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(getRR.Body.Bytes())) + post.Header.Set("Content-Type", "application/json") + postRR := httptest.NewRecorder() + srv.Handler().ServeHTTP(postRR, post) + if postRR.Code != http.StatusOK { + t.Fatalf("POST /api/config = %d: %s", postRR.Code, postRR.Body.String()) + } + + restarted, err := config.Load(configPath) + if err != nil { + t.Fatalf("reload saved config: %v", err) + } + if !restarted.AppLink.On() { + t.Fatal("settings save changed the defaulted app link to off after restart") + } +} + +func TestAppLinkOldFullConfigClientUsesEffectiveRestartState(t *testing.T) { + cases := []struct { + name string + postedValue any + includeKey bool + wantOn bool + wantRestart bool + }{ + {name: "omitted", wantOn: true}, + {name: "explicit null", includeKey: true, wantRestart: true}, + {name: "explicit false", postedValue: map[string]any{"enabled": false}, includeKey: true, wantRestart: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + oldYAML := []byte("site:\n name: Test\nfuse:\n max_amps: 16\n phases: 3\n voltage: 230\napi:\n port: 8080\ndrivers: []\n") + if err := os.WriteFile(configPath, oldYAML, 0o600); err != nil { + t.Fatalf("write old config: %v", err) + } + + cfg, err := config.Load(configPath) + if err != nil { + t.Fatalf("load old config: %v", err) + } + var cfgMu sync.RWMutex + var ctrlMu sync.Mutex + srv := api.New(&api.Deps{ + Ctrl: control.NewState(0, 42, ""), CtrlMu: &ctrlMu, + Cfg: cfg, CfgMu: &cfgMu, + ConfigPath: configPath, + DriverDir: dir, UserDriverDir: dir, + SaveConfig: config.SaveAtomic, + }) + + get := httptest.NewRequest(http.MethodGet, "/api/config", nil) + getRR := httptest.NewRecorder() + srv.Handler().ServeHTTP(getRR, get) + if getRR.Code != http.StatusOK { + t.Fatalf("GET /api/config = %d: %s", getRR.Code, getRR.Body.String()) + } + var posted map[string]any + if err := json.Unmarshal(getRR.Body.Bytes(), &posted); err != nil { + t.Fatalf("decode GET /api/config: %v", err) + } + delete(posted, "app_link") + if tc.includeKey { + posted["app_link"] = tc.postedValue + } + body, err := json.Marshal(posted) + if err != nil { + t.Fatalf("encode old-client config: %v", err) + } + + post := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body)) + post.Header.Set("Content-Type", "application/json") + postRR := httptest.NewRecorder() + srv.Handler().ServeHTTP(postRR, post) + if postRR.Code != http.StatusOK { + t.Fatalf("POST /api/config = %d: %s", postRR.Code, postRR.Body.String()) + } + var response struct { + RestartRequired bool `json:"restart_required"` + } + if err := json.Unmarshal(postRR.Body.Bytes(), &response); err != nil { + t.Fatalf("decode POST /api/config: %v", err) + } + if response.RestartRequired != tc.wantRestart { + t.Fatalf("restart_required = %v, want %v; response: %s", response.RestartRequired, tc.wantRestart, postRR.Body.String()) + } + + restarted, err := config.Load(configPath) + if err != nil { + t.Fatalf("reload saved config: %v", err) + } + if got := restarted.AppLink.On(); got != tc.wantOn { + t.Fatalf("app link after restart = %v, want %v", got, tc.wantOn) + } + }) + } +} + +func TestEmptyAppLinkSectionRemainsOff(t *testing.T) { + cfg, err := config.Parse([]byte("site:\n name: Test\nfuse:\n max_amps: 16\n phases: 3\n voltage: 230\napi:\n port: 8080\ndrivers: []\napp_link: {}\n"), t.TempDir()) + if err != nil { + t.Fatalf("parse: %v", err) + } + if cfg.AppLink == nil || cfg.AppLink.On() { + t.Fatalf("empty app_link section must remain off, got %+v", cfg.AppLink) + } +} + +func TestAppLinkNullPostedThroughAPIStaysOffAfterRestart(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + cfg := &config.Config{} + var cfgMu sync.RWMutex + var ctrlMu sync.Mutex + srv := api.New(&api.Deps{ + Ctrl: control.NewState(0, 42, ""), CtrlMu: &ctrlMu, + Cfg: cfg, CfgMu: &cfgMu, + ConfigPath: configPath, + DriverDir: dir, UserDriverDir: dir, + SaveConfig: config.SaveAtomic, + }) + body := []byte(`{ + "site": {"name": "Test", "smoothing_alpha": 0.3}, + "fuse": {"max_amps": 16, "phases": 3, "voltage": 230}, + "api": {"port": 8080}, + "drivers": [], + "app_link": null +}`) + req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST /api/config = %d: %s", rr.Code, rr.Body.String()) + } + + restarted, err := config.Load(configPath) + if err != nil { + t.Fatalf("reload saved config: %v", err) + } + if restarted.AppLink == nil || restarted.AppLink.On() { + t.Fatalf("explicit JSON null did not persist as disabled: %+v", restarted.AppLink) + } +} diff --git a/go/internal/api/api.go b/go/internal/api/api.go index a72b0b95a..bf5686d95 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1421,11 +1421,23 @@ func restoreDriverConfigSecrets(incoming, existing *config.Config, secretsByLua } func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) { - var newCfg config.Config - if err := readJSON(r, &newCfg); err != nil { + var posted struct { + config.Config + AppLink json.RawMessage `json:"app_link"` + } + if err := readJSON(r, &posted); err != nil { writeJSON(w, 400, map[string]string{"error": "invalid config: " + err.Error()}) return } + newCfg := posted.Config + if posted.AppLink != nil { + if bytes.Equal(bytes.TrimSpace(posted.AppLink), []byte("null")) { + newCfg.AppLink = &config.AppLink{Enabled: false} + } else if err := json.Unmarshal(posted.AppLink, &newCfg.AppLink); err != nil { + writeJSON(w, 400, map[string]string{"error": "invalid config: app_link: " + err.Error()}) + return + } + } // Preserve secrets the UI sent back as empty (masked) values. s.deps.CfgMu.RLock() newCfg.PreserveMaskedSecrets(s.deps.Cfg) diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index b698edebe..d324b700f 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -1,5 +1,5 @@ -// Package appuplink keeps the box's outbound connection to the blind relay, -// and runs the app sessions that arrive over it. +// Package appuplink keeps the box's outbound connection to the content-blind +// relay, 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 @@ -38,7 +38,8 @@ import ( // 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 blind anyway, so there is nothing to gain by choosing one. +// and the relay is fixed by the protocol, so there is nothing to gain by +// choosing one. const Endpoint = "wss://relay.ftw.energy" // The relay's close codes and control words. Held here rather than shared with @@ -73,13 +74,11 @@ const ( // rotateJitter is how long a box waits before rejoining after an epoch // turned over. // - // Not zero, and this is the point of it. The relay closes the old handle - // and the new one appears when the peer comes back; returning instantly - // lets a relay watching the clock line the two epochs up by timing alone, - // which is the correlation rotation exists to prevent. The relay already - // spreads the closes across five minutes, so a few seconds here is enough - // to put other households' rotations in between — and short enough that - // the phone in someone's hand does not notice. + // Not zero. The relay closes the old handle and the new one appears when + // the peer comes back; returning instantly gives it a stronger timing link + // between the two protocol handles. The relay already spreads the closes + // across five minutes, so a few seconds here weakens that signal without + // making the phone wait. It cannot hide IP or connection continuity. rotateJitter = 5 * time.Second // correctionLimit is how many epoch corrections we take at full speed diff --git a/go/internal/appuplink/deadman.go b/go/internal/appuplink/deadman.go index ac63623dd..c4043aaf0 100644 --- a/go/internal/appuplink/deadman.go +++ b/go/internal/appuplink/deadman.go @@ -88,7 +88,8 @@ type DeadmanSource interface { // rendezvous secret over a versioned purpose tag and the subscription id, // truncated to 16 bytes — 32 lowercase hex chars, as the contract asks. // Deterministic so a reboot claims the same ids it posted; keyed and tagged -// so the relay can link neither id to a rendezvous handle nor id to id. +// so the values alone link neither id to a rendezvous handle nor id to id. +// Source IP and request timing remain visible to the relay. func DeadmanID(secret []byte, subscriptionID string) string { mac := hmac.New(sha256.New, secret) mac.Write([]byte("ftw deadman v1\x00" + subscriptionID)) diff --git a/go/internal/appuplink/rendezvous.go b/go/internal/appuplink/rendezvous.go index ad80f6928..0b9a6d854 100644 --- a/go/internal/appuplink/rendezvous.go +++ b/go/internal/appuplink/rendezvous.go @@ -9,28 +9,30 @@ import ( // The rendezvous handle: what the box and the app call each other on the relay. // -// It is the one piece of metadata the relay unavoidably sees, so what it is -// made of decides how much the relay learns. A stable handle — a hash of the -// box's static key, say — would work perfectly and hand the operator a -// household identifier good for years: every connection, every outage, every -// holiday, joined up under one string. +// It is the one piece of protocol metadata the relay unavoidably sees, so what +// it is made of decides how much the protocol itself reveals. A stable handle +// — a hash of the box's static key, say — would work perfectly and hand the +// operator a household identifier good for years: every connection, every +// outage, every holiday, joined up under one string. // // So it is derived per epoch from a secret the relay never sees: // // handle = HKDF-SHA256(secret, info = "ftw/rendezvous/v1/")[0..16] // // HKDF-Expand is a PRF, so without the secret two epochs' handles are two -// unrelated strings and there is no function the relay can compute that links -// them. The relay's only contribution is the epoch *number*, which it -// announces to everyone equally because it is its own clock. +// cryptographically unrelated strings and there is no function the relay can +// compute from the handles alone that links them. This removes a stable +// protocol identifier; it does not hide source IP, timing or connection +// continuity. The relay's only contribution to the derivation is the epoch +// *number*, which it announces to everyone equally because it is its own clock. // // This must agree byte for byte with srcfl/ftw-webapp // src/lib/carrier/rendezvous.ts. Disagreeing means the box and the app sit in // two different rooms and nobody's house appears on their phone. const ( - // EpochMs is how long a handle lives. An hour is short enough that a - // handle is not a household identifier, and long enough that rotations - // are rare next to the reconnects a phone does anyway. + // EpochMs is how long a stable protocol handle lives. An hour limits that + // identifier's lifetime and keeps rotations rare next to the reconnects a + // phone does anyway. Network metadata may still correlate the sockets. EpochMs = 3_600_000 // HandleBytes is 128 bits: long enough that handles never collide, diff --git a/go/internal/appuplink/uplink_test.go b/go/internal/appuplink/uplink_test.go index 93702c2ad..d8ed2f134 100644 --- a/go/internal/appuplink/uplink_test.go +++ b/go/internal/appuplink/uplink_test.go @@ -315,8 +315,8 @@ func TestAWrongPairingCodeIsRefused(t *testing.T) { } // The box joins under the handle its own secret derives, not under anything -// the relay chose. If this ever became a constant the relay could correlate a -// household across years. +// the relay chose. If this ever became a constant the protocol would hand the +// relay a household identifier that lasts for years. func TestTheBoxJoinsUnderTheDerivedHandle(t *testing.T) { r := newRig(t, 481234) @@ -367,9 +367,9 @@ func TestAnImplausibleEpochCorrectionIsIgnored(t *testing.T) { } } -// A box closed at a rotation must not come straight back. The relay would -// otherwise watch one handle go quiet and another appear in the same -// millisecond, and link the two epochs by timing alone. +// A box closed at a rotation must not come straight back. That would give the +// relay the strongest possible timing link between the old and new protocol +// handles, even though IP and connection metadata can still correlate them. func TestRotationIsNotFollowedInstantly(t *testing.T) { relay := newFakeRelay(481234) defer relay.close() diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 2287c056a..c328ff48c 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -45,14 +45,24 @@ type Config struct { DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` } -// AppLink enables the outbound connection the FTW app reaches this box -// through. One switch: the relay is blind and fixed by the protocol, so there -// is no endpoint to choose and no transport to pick. A question the site owner -// cannot answer is the wrong question. +// 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. type AppLink struct { Enabled bool `yaml:"enabled" json:"enabled"` } +// On is the single reading of the app-link switch. A nil section means an old +// config that has never made the choice, so it follows the default. An empty +// section has Enabled's false zero value and remains an opt-out. +func (a *AppLink) On() bool { + if a == nil { + return true + } + return a.Enabled +} + // 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 @@ -1265,10 +1275,20 @@ func Load(path string) (*Config, error) { // Parse parses config bytes and validates. baseDir resolves driver Lua paths. func Parse(data []byte, baseDir string) (*Config, error) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } var c Config - if err := yaml.Unmarshal(data, &c); err != nil { + if err := doc.Decode(&c); err != nil { return nil, fmt.Errorf("yaml: %w", err) } + // An omitted app_link section follows the new default. An explicit YAML + // null was a valid opt-out before that default changed, so retain it as an + // explicit disabled section instead of letting applyDefaults turn it on. + if topLevelYAMLNull(&doc, "app_link") { + c.AppLink = &AppLink{Enabled: false} + } applyDefaults(&c) if err := c.Validate(); err != nil { return nil, err @@ -1277,6 +1297,32 @@ func Parse(data []byte, baseDir string) (*Config, error) { return &c, nil } +func topLevelYAMLNull(doc *yaml.Node, key string) bool { + if doc == nil || doc.Kind != yaml.DocumentNode || len(doc.Content) != 1 { + return false + } + // Node values leave ignored subtrees opaque while yaml.v3 applies merge + // precedence to the root keys. + var values map[string]yaml.Node + if err := doc.Content[0].Decode(&values); err != nil { + return true + } + value, ok := values[key] + if !ok { + return false + } + node := &value + seen := make(map[*yaml.Node]bool) + for node != nil && node.Kind == yaml.AliasNode { + if seen[node] { + return true + } + seen[node] = true + node = node.Alias + } + return node == nil || node.ShortTag() == "!!null" +} + // DriversDirOverride redirects resolution of relative "drivers/.lua" // Lua paths to this directory instead of the config sibling. main.go sets // it once at startup from the -drivers flag so Docker images — where @@ -1429,6 +1475,13 @@ func applyDefaults(c *Config) { }} } } + if c.AppLink == nil { + // The app relay carries only end-to-end encrypted frames under a handle + // that changes every hour. It still sees connection metadata. Existing + // sites with no app_link section join the supported remote path after + // upgrading; an explicit enabled:false block remains the operator opt-out. + c.AppLink = &AppLink{Enabled: true} + } if c.FleetPing == nil { // Absent means never configured, which is the state every existing // box is in. Enabled is the owner's call; an explicit diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 0bb95208b..346304641 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -946,6 +946,52 @@ func TestSlewExplicitDisablePreserved(t *testing.T) { } } +func TestAppLinkDefaultsOnAndPreservesOptOut(t *testing.T) { + absent := &Config{} + applyDefaults(absent) + if absent.AppLink == nil || !absent.AppLink.Enabled { + t.Fatalf("omitted app_link must default on, got %+v", absent.AppLink) + } + + disabled := &Config{AppLink: &AppLink{Enabled: false}} + applyDefaults(disabled) + if disabled.AppLink.Enabled { + t.Fatal("explicit app_link opt-out was overwritten") + } +} + +func TestParseAppLinkPreservesExplicitNullOptOut(t *testing.T) { + tests := []struct { + name string + suffix string + enabled bool + }{ + {name: "omitted", enabled: true}, + {name: "bare null", suffix: "app_link:\n", enabled: false}, + {name: "named null", suffix: "app_link: null\n", enabled: false}, + {name: "null alias", suffix: "disabled: &disabled null\napp_link: *disabled\n", enabled: false}, + {name: "null with ignored complex key", suffix: "legacy:\n ? [old, key]\n : ignored\napp_link: null\n", enabled: false}, + {name: "merged null", suffix: "defaults: &defaults\n app_link: null\n<<: *defaults\n", enabled: false}, + {name: "merged sequence first null", suffix: "off: &off {app_link: null}\non: &on {app_link: {enabled: true}}\n<<: [*off, *on]\n", enabled: false}, + {name: "merged sequence first true", suffix: "off: &off {app_link: null}\non: &on {app_link: {enabled: true}}\n<<: [*on, *off]\n", enabled: true}, + {name: "direct true overrides merged null", suffix: "off: &off {app_link: null}\n<<: *off\napp_link: {enabled: true}\n", enabled: true}, + {name: "empty mapping", suffix: "app_link: {}\n", enabled: false}, + {name: "explicit false", suffix: "app_link:\n enabled: false\n", enabled: false}, + {name: "explicit true", suffix: "app_link:\n enabled: true\n", enabled: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Parse([]byte(minimalYAML+tt.suffix), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if got := cfg.AppLink.On(); got != tt.enabled { + t.Fatalf("app link enabled = %v, want %v", got, tt.enabled) + } + }) + } +} + func TestNotificationsDefaults(t *testing.T) { c := &Config{Notifications: &Notifications{Enabled: false}} applyDefaults(c) diff --git a/go/internal/config/restart_required.go b/go/internal/config/restart_required.go index b75b93215..273d5144d 100644 --- a/go/internal/config/restart_required.go +++ b/go/internal/config/restart_required.go @@ -66,7 +66,7 @@ func RestartRequiredFor(oldCfg, newCfg *Config) []string { if !pointerEqual(oldCfg.Nova, newCfg.Nova) { reasons = append(reasons, "nova — federation client is constructed once at startup") } - if !pointerEqual(oldCfg.AppLink, newCfg.AppLink) { + if oldCfg.AppLink.On() != newCfg.AppLink.On() { reasons = append(reasons, "app_link — the app uplink is connected at startup") } // fleet_ping.enabled is read at each send, so the switch takes effect at diff --git a/go/internal/config/restart_required_test.go b/go/internal/config/restart_required_test.go index 722179fc8..0a3f23a63 100644 --- a/go/internal/config/restart_required_test.go +++ b/go/internal/config/restart_required_test.go @@ -78,7 +78,7 @@ func TestRestartRequiredFor_BootSections(t *testing.T) { {"price provider", func(c *Config) { c.Price = &Price{Provider: "entsoe"} }, "price"}, {"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", func(c *Config) { c.AppLink = &AppLink{Enabled: true} }, "app_link"}, + {"app link toggled off", func(c *Config) { c.AppLink = &AppLink{Enabled: false} }, "app_link"}, {"ev_charger added", func(c *Config) { c.EVCharger = &EVCharger{Provider: "easee", Username: "a@b.c"} }, "ev_charger"}, diff --git a/web/app-link-tab.test.mjs b/web/app-link-tab.test.mjs index ac1ed7c03..f55503976 100644 --- a/web/app-link-tab.test.mjs +++ b/web/app-link-tab.test.mjs @@ -51,15 +51,14 @@ describe("the app tab", () => { }); it("creates the config section when it is missing entirely", () => { - // A box that has never had app_link in its YAML — the state Fredrik's - // box was in. Without this the checkbox writes into nothing and Save - // posts a config with no app_link key at all. + // Load normally supplies the default. Keep the tab safe for partial config + // fixtures and old API responses too, or the checkbox writes into nothing. const config = {}; render(config); // Compared field by field: the object is built inside the vm sandbox, so // its prototype comes from another realm and deepStrictEqual refuses it. assert.ok(config.app_link, "app_link was not created"); - assert.equal(config.app_link.enabled, false); + assert.equal(config.app_link.enabled, true); }); it("says a restart is needed", () => { @@ -68,6 +67,14 @@ describe("the app tab", () => { assert.match(render({}), /[Rr]estart/); }); + it("states both the encrypted content and visible connection metadata", () => { + const html = render({}); + assert.match(html, /end-to-end encrypted/); + assert.match(html, /IP/); + assert.match(html, /when the box is connected/); + assert.match(html, /cannot read/); + }); + it("starts with the pairing button disabled", () => { // It is enabled once /api/app-link/status reports the uplink running. // Starting enabled means the first press of a fresh page fails. diff --git a/web/settings/tabs/app.js b/web/settings/tabs/app.js index f6812fb2e..4f3160939 100644 --- a/web/settings/tabs/app.js +++ b/web/settings/tabs/app.js @@ -376,7 +376,7 @@ S.tabs.app = { render: function (ctx) { - if (!ctx.config.app_link) ctx.config.app_link = { enabled: false }; + if (!ctx.config.app_link) ctx.config.app_link = { enabled: true }; var enabled = !!ctx.config.app_link.enabled; pairingCtx = ctx; @@ -407,9 +407,10 @@ return ( "
The FTW app" + - '

The FTW app talks to this box directly. Turning this on ' + - "lets it reach you when you are away from home; nothing readable passes " + - "through Sourceful either way.

" + + '

The FTW app talks to this box directly. Keeping this on ' + + "lets it reach you when you are away from home. Readings and commands are " + + "end-to-end encrypted. Sourceful can see your IP and when the box is " + + "connected, but cannot read them.

" + '" +