diff --git a/.changeset/anonymous-daily-energy.md b/.changeset/anonymous-daily-energy.md new file mode 100644 index 000000000..b97cbb6a7 --- /dev/null +++ b/.changeset/anonymous-daily-energy.md @@ -0,0 +1,7 @@ +--- +"ftw": minor +--- + +Statistics are one answer on the box, for two anonymous daily reports. With FTW Cloud they are part of Cloud. Without Cloud they are on until you turn them off under Settings → FTW app → Anonymous statistics, which shows both messages; nobody is asked any more. A No, a fleet report switched off, or Cloud turned off on an earlier release stays off. + +Beside the fleet report, the box can now send one energy report a day: the date and that UTC day's solar generation, grid import and export, battery charge and discharge and vehicle charge, in whole kWh, never in the same message as the fleet report and at its own time. It skips any day with a gap in the energy record and any site a partner controls. Once a release names an Oblivious HTTP relay, both reports go through it, so FTW Cloud never sees the box's address. This release names none yet: the energy report is not sent, a box that said yes sends the fleet report straight to FTW Cloud as before, and a box that shares only by default sends nothing until a relay is named. diff --git a/config.example.yaml b/config.example.yaml index 249d228ff..c8e9d86a7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -220,16 +220,20 @@ state: app_link: 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 -# types it runs, roughly how much battery it has, its price zone and roughly -# how old the install is. No id, no key, no serial, no site name, no counter -# and no timestamp — nothing in the message says which box sent it. Two -# limits: those six fields still describe a household, so a rare mix may be -# the only one of its kind, and the relay sees the address during the request. -# It saves only daily totals, so the result counts reports, not unique boxes. -# Settings → FTW app → Fleet statistics shows the exact message this box would send. On by -# default; `enabled: false` turns it off without a restart. +# Anonymous daily reports. Statistics are one answer on the box: part of FTW +# Cloud, and on for a box without Cloud until its owner turns them off under +# Settings → FTW app → Anonymous statistics, which shows each message. Two +# reports, each alone and at its own time. The fleet report carries six +# things: FTW version, release channel, driver types, roughly how much +# battery, price zone and roughly how old the install is. The energy report +# carries a closed UTC day's six whole-kWh sums. No box id, no key, no serial, +# no site name, no counter. Once a release names a relay, both go through it, +# sealed with a random number drawn new for each report and the day it was +# sent, so FTW Cloud never sees the address; until then the fleet report goes +# straight to `endpoint`, which sees the address, and the energy report is not +# sent. +# `enabled: false` from an earlier release is read once and kept as "off"; it +# is no longer a switch of its own. fleet_ping: enabled: true # endpoint: https://cloud.ftw.energy/v1/fleet diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index 6915dbf55..9a5ba5274 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -162,6 +162,9 @@ Owners who decline use the box locally without Cloud. Local-only users get a separate question, with Yes as the suggested action. Nothing starts before an active answer. No leaves local control working. +(Replaced on 24 October 2026: a box without Cloud shares the two anonymous +reports until its owner turns them off; see "Anonymous daily reports and one +statistics answer" below.) This statistics-only link publishes no phone keys, pairing codes, Noise key, read grant, live state or remote access. It reuses the signed upload path. A policy or purpose change requires a new version and a fresh answer. @@ -329,6 +332,8 @@ who declines its terms pauses Cloud on that phone and keeps its local keys. The daily anonymous report also waits for the current project choice. Its saved config switch cannot bypass a missing answer or a local No. +(Replaced on 24 October 2026; see "Anonymous daily reports and one +statistics answer" below.) A new policy rotates the stats id and starts a new reporting window. An unset wall clock starts that window only once time is known; no earlier hours are filled in. @@ -350,3 +355,107 @@ even if an owner previously chose to contribute. The owner policy response reports this readiness so native clients can explain the wait. Pausing the service policy stops collection while withdrawal and retention still run. Deploying this code does not sign the DPIA or accept a household's consent. + +### Anonymous daily reports and one statistics answer, October 2026 + +Fredrik decided this on 24 October 2026, so that stats.ftw.energy can show +energy without C8's household rows, and so that statistics are one thing: +"antingen delar man med sig av stats eller inte". + +**One answer.** Statistics are one answer on the box that covers both +anonymous daily reports, with no second setting: + +- With FTW Cloud they are part of Cloud, with no separate off switch, as in + the 17 October amendment. A Cloud grant under older terms sends nothing + until the owner accepts the current ones. +- Without Cloud they are on until the owner turns them off, under Settings → + FTW app → Anonymous statistics, where the box shows each message. Nobody is + asked any more; the local question and its "nothing before an answer" rule + are gone. +- Stored state is read once (`cloudlink.MigrateStatistics`). A local No, a + fleet report switched off in the config (`fleet_ping.enabled: false`), or + Cloud turned off before this release (whose screen said that stops + statistics) stays off. Every other box is on. `fleet_ping.enabled` is no + longer a switch. +- A box without Cloud no longer starts a statistics-only Cloud link. Those + already running keep working until turned off. Hourly C8 rows for local + boxes therefore have no way in; if C8 is ever activated for them, it needs + its own consent. +- The hourly C8 upload follows the same answer: off, or Cloud under older + terms, sends no hour whatever the grant carries, and an answer that is off + takes the stats purpose out of the grant. Under older terms, asking for + statistics is refused until the owner accepts the current terms. +- A stored grant counts only through the running link, which honours it only + with the key it was made with. + +**Two reports, never one message.** Each leaves at its own random time: + +- the fleet report (`go/internal/fleetping`): version, channel, drivers, + battery size, price zone, install age, as before; +- the energy report (`go/internal/energyreport`): once per closed UTC day, + 1 to 12 hours after it closes, the date and six whole-kWh sums (solar + generation, grid import and export, battery charge and discharge, vehicle + charge). A day with a gap in the energy ledger is skipped, never sent in + part. Never for a site a partner controls. + +With a fleet of about eight boxes, a driver set such as Sungrow, Easee, +myuplink and tesla-ha in SE4 points at one home. Energy sent beside it would +be that home's energy again. So the two share no field and never travel +together. + +**Path.** Both go through one independent Oblivious HTTP relay (RFC 9458) to +FTW Cloud's gateway, `POST /v1/ohttp` in `ftwcloudd`, as one padded inner +request each (`go/protocol/reportwire`, 1,536 bytes; the relay always sees +1,591). Inside, each report carries a random id and the UTC date it was +sent. The gateway reads the schema, adds the report to its own day totals +(`/v1/fleet/public`, `/v1/energy/public`) and keeps nothing per report but +a hash of its id, for three days, so a replay or a retry counts once +(RFC 9458, section 6.5). A report sent before yesterday is refused. Both +public views list closed days only, so nobody can match a new count to the +report that just arrived. +`go/internal/statsrelay` holds the relay URL and the gateway key as +compile-time pins. The box encrypts only to the pinned key and never asks the +service for one, so every box on a release uses the same key and a key +swapped in at the service opens nothing. Until both pins are set, the energy +report is not sent, and the fleet report posts straight to `/v1/fleet` as +before, but only from a box that said yes: FTW Cloud's terms or a local yes. +A box that shares only by default never posts straight to FTW Cloud, which +would see its address; it waits for the relay (Fredrik, 24 October 2026). A +build with the relay sends the fleet report to FTW Cloud only through it. +`POST /v1/fleet` stays for boxes on older releases; it can go one stable +release after the first that pins the relay. A collector the household set +in `fleet_ping.endpoint` is still posted to directly. + +**Who sees what.** The relay sees the box's address, the time and a +ciphertext of fixed length, never a report. Cloudflare's relay, if chosen, +keeps those logs for about 124 days. The gateway sees each report, the +relay's address and the time, never the box's address. FTW Cloud keeps per +date only the counts and sums; a public energy date appears once no report +may still name it, four days after it ends, and needs at least 5 reports. A +sum that one to four of them had any of shows null: five reports with one car +would otherwise publish that car's charge. + +Limits, plainly: + +- The relay and Sourceful together could join reports to addresses by time. + The relay's independence is the control. +- FTW Cloud holds readings from homes whose owners turned on the readable + copy, including 48 hours of history tiles. The same daily sums could be + computed from them and matched with a report. The gateway does not do this + and keeps nothing to match with, but that is a rule in code, not one the + cryptography enforces. +- The floor of five counts reports, not boxes; Fredrik accepted this on + 24 October 2026. An anonymous report cannot prove that a real box sent + it. The relay alone can drop the other reports and add fakes to isolate + one home's day, and that shows on our side as missing reports. Anyone + else can do it only on a day when one real home reported. Rejecting + replays and listing closed days only remove the cheap attacks. Anonymous + tokens (Privacy Pass) are the later fix, once the fleet grows or someone + shows a need. + +**Open for Fredrik's lawyer, not decided here.** Reports that are anonymous +to Sourceful through OHTTP should be outside GDPR. Reading data from the +owner's equipment to make them may still need consent under the ePrivacy +rule (Swedish LEK, chapter 9, section 28). If it does, neither the opt-out for +boxes without Cloud nor the requirement with Cloud holds, and both need an +active yes. Fredrik chose this design; it is built as he asked. C8 stays off. diff --git a/docs/architecture.md b/docs/architecture.md index 70bcd14b1..993a3cebd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -614,10 +614,14 @@ The box's anonymous daily report is separate from the Cloud readable-copy grant. Once a day it posts its FTW version and channel, the driver types in use, a battery-capacity bucket, its price zone and an install-age bucket. It answers how many reports arrive and what they describe, which helps size engineering -bets. - -It goes over HTTPS to `https://cloud.ftw.energy/v1/fleet`. It never enters the -Noise session. The HTTP handler validates the fixed payload, adds it to the UTC +bets. It is one of two anonymous daily reports under one statistics answer, with +the energy report beside it (ADR 0008). + +Once a release names an Oblivious HTTP relay (`go/internal/statsrelay`), it goes +through that relay to FTW Cloud's gateway, sealed, so FTW Cloud never sees the +box's address. Until then it goes over HTTPS to +`https://cloud.ftw.energy/v1/fleet`, and only from a box that said yes. It never +enters the Noise session. The HTTP handler validates the fixed payload, adds it to the UTC day's counters and drops it. It keeps 90 calendar days of totals, not raw reports. An operator reads totals only on the loopback metrics listener. An explicitly saved former default resolves to Cloud; a private collector keeps @@ -627,7 +631,11 @@ Fleet totals must not form a household record. The message carries no gateway ID name, no counter and no timestamp — nothing in it says which box sent it — values are bucketed rather than reported, the version travels only when it is a release tag so a developer's build reports as unknown, and the send time is -drawn fresh each day rather than sitting in one slot. +drawn fresh each day rather than sitting in one slot. Through the relay the +sealed request also carries a random number, drawn new for each report so FTW +Cloud counts a report once if it arrives twice, and the UTC day it was sent, so +an old request is refused. Neither names the box, and FTW Cloud keeps a hash of +the number for three days only. Driver names get their own rule, because a driver file's name is whatever the thing that installed it called it, and a household can install its own. A name @@ -674,14 +682,16 @@ Cloud service's loopback stats response. The fields still describe a household, payload remains a quasi-identifier: a beta box in a small price zone with a big battery may be the only one like it, and coarse buckets with a small field set are what keep that population large rather than a proof that it is. The endpoint -also sees the source IP while the request is open, though the Cloud service writes no -request log or source address. And with no id there is no way to dedupe, so the totals count -reports, not unique boxes. - -A failed send is forgotten, never retried. Settings → FTW app → Fleet statistics renders the -exact payload from the same call the sender uses, so the claim is checkable -rather than promised. It is on by default; saving `enabled: false` opts out -without a restart. See [`go/internal/fleetping`](../go/internal/fleetping). +also sees the source IP while the request is open when the report goes straight +to it, though the Cloud service writes no request log or source address; through +the relay it does not. And nothing ties two reports to one box, so the totals +count reports, not unique boxes. + +A failed send is forgotten, never retried. Settings → FTW app → Anonymous +statistics renders the exact payload from the same call the sender uses, and +what goes beside it, so the claim is checkable rather than promised. It follows +the one statistics answer, whose switch is on that screen. See +[`go/internal/fleetping`](../go/internal/fleetping). ## Releases diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 268dbd833..bde98a520 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -1858,3 +1858,9 @@ codes, Noise key or browser subscriptions. It grants no remote access. Core also rejects tunnels locally in that mode and emits no live or event data. Collection stays off until the signed C8 review; the default operator setting is empty. Withdrawal and retention continue while it is paused. + +Since 24 October 2026 a box without Cloud no longer starts a statistics-only +roster: its statistics are the two anonymous daily reports, through the +Oblivious HTTP relay (ADR 0008, "Anonymous daily reports and one statistics +answer"). Statistics-only rosters already running keep working until the +owner turns statistics off. diff --git a/go/cmd/ftw/energy_report.go b/go/cmd/ftw/energy_report.go new file mode 100644 index 000000000..e40d8b84b --- /dev/null +++ b/go/cmd/ftw/energy_report.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "log/slog" + "sync" + + "github.com/srcfl/ftw/go/internal/cloudlink" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/energyreport" + "github.com/srcfl/ftw/go/internal/state" +) + +// The anonymous daily energy report, wired to the box. What it sends and how +// is decided in go/internal/energyreport; this file only gives it the ledger +// and the owner's answer. + +// startEnergyReport builds the sender and starts its schedule. It is built +// even when nothing may be sent, so Settings can show the report this box +// would send. A build with no relay or gateway key sends nothing at all. +func startEnergyReport(ctx context.Context, cfg *config.Config, cfgMu *sync.RWMutex, st *state.Store, answer statsAnswer) *energyreport.Sender { + if st == nil { + return nil + } + sender, err := energyreport.New(energyreport.Options{ + Enabled: energyReportAllowed(cfg, cfgMu, answer), + Ledger: st, + KV: st, + ClockSynced: cloudlink.ClockSynced, + }) + if err != nil { + slog.Warn("energy report unavailable", "err", err) + return nil + } + go sender.Run(ctx) + return sender +} + +// energyReportAllowed is the fleet report's answer, and no site a partner +// controls: as for the hourly project statistics, a partner's contract has to +// let such a home in first. +func energyReportAllowed(cfg *config.Config, cfgMu *sync.RWMutex, answer statsAnswer) func() bool { + return func() bool { + cfgMu.RLock() + partner := cfg.Nova != nil && cfg.Nova.Enabled + cfgMu.RUnlock() + return !partner && answer.shares() + } +} diff --git a/go/cmd/ftw/energy_report_test.go b/go/cmd/ftw/energy_report_test.go new file mode 100644 index 000000000..f2423a58f --- /dev/null +++ b/go/cmd/ftw/energy_report_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/fleetping" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/statsrelay" + "github.com/srcfl/ftw/go/internal/statsrelay/statsrelaytest" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Both reports read one answer: part of FTW Cloud, on for a box without it +// until the owner turns it off. The energy report also leaves out a site a +// partner controls. +func TestBothReportsReadTheOneStatisticsAnswer(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + cfg := &config.Config{FleetPing: &config.FleetPing{Enabled: true}} + cfgMu := &sync.RWMutex{} + answer := statsAnswer{st: st} + energy, fleet := energyReportAllowed(cfg, cfgMu, answer), answer.shares + check := func(name string, wantEnergy, wantFleet bool) { + t.Helper() + if energy() != wantEnergy || fleet() != wantFleet { + t.Fatalf("%s: energy %v fleet %v, want %v %v", name, energy(), fleet(), wantEnergy, wantFleet) + } + } + + check("before the stored state is read", false, false) + migrateStatistics(cfg, cfgMu, st, nil) + check("a box without Cloud, never asked", true, true) + + // The old switch is read once and is no longer a second setting. + cfg.FleetPing.Enabled = false + check("the retired fleet switch", true, true) + + cfg.Nova = &config.Nova{Enabled: true} + check("a partner controls the site", false, true) + cfg.Nova = nil + + if err := st.SaveConfig("statistics/share_v1", "off"); err != nil { + t.Fatal(err) + } + check("the owner turned statistics off", false, false) + + // A stored Cloud grant counts only through a running link, which honours + // it only with its key (cloudlink's tests). Without one, the box's own + // answer stands. + grant, _ := json.Marshal(map[string]any{"policy": cloudwire.CloudTermsPolicy, "purposes": []string{"serve", "stats"}}) + if err := st.SaveConfig("cloud/grant_v1", string(grant)); err != nil { + t.Fatal(err) + } + check("a stored grant with no link", false, false) +} + +// A box that shares only by default posts nothing straight to FTW Cloud: its +// fleet report waits for the relay. A yes, the relay or the household's own +// collector lets it go. +func TestADefaultBoxSendsNothingStraightToFTWCloud(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + migrateStatistics(&config.Config{}, &sync.RWMutex{}, st, nil) + answer := statsAnswer{st: st} + client, _, _ := statsrelaytest.New(t, time.Now()) + direct, _ := fleetProvider(config.DefaultFleetPingEndpoint, nil) + relayed, _ := fleetProvider(config.DefaultFleetPingEndpoint, client) + own, _ := fleetProvider("https://collector.example.test/fleet", nil) + if fleetReportAllowed(answer, config.DefaultFleetPingEndpoint, direct)() { + t.Fatal("a box that never said yes posted straight to FTW Cloud") + } + if !fleetReportAllowed(answer, config.DefaultFleetPingEndpoint, relayed)() || !fleetReportAllowed(answer, "https://collector.example.test/fleet", own)() { + t.Fatal("the relay or the household's collector was held back") + } + if err := st.SaveConfig("statistics/share_v1", "on"); err != nil { + t.Fatal(err) + } + if !fleetReportAllowed(answer, config.DefaultFleetPingEndpoint, direct)() { + t.Fatal("a yes did not send") + } +} + +// A fleet report switched off before this release stays off. +func TestASwitchedOffFleetReportStaysOff(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + cfg := &config.Config{FleetPing: &config.FleetPing{Enabled: false}} + migrateStatistics(cfg, &sync.RWMutex{}, st, nil) + if (statsAnswer{st: st}).shares() { + t.Fatal("an earlier off was turned on") + } +} + +// A build with a relay sends the fleet report to FTW Cloud only through it. +// A collector the household chose is posted to directly, and a build without +// a relay keeps the direct path. +func TestTheFleetReportTakesTheRelayToFTWCloud(t *testing.T) { + client, _, _ := statsrelaytest.New(t, time.Now()) + for name, c := range map[string]struct { + endpoint string + relay bool + want bool + }{ + "FTW Cloud with a relay": {config.DefaultFleetPingEndpoint, true, true}, + "FTW Cloud without a relay": {config.DefaultFleetPingEndpoint, false, false}, + "a household's collector": {"https://collector.example.test/fleet", true, false}, + } { + var relay *statsrelay.Client + if c.relay { + relay = client + } + p, err := fleetProvider(c.endpoint, relay) + if err != nil { + t.Fatal(err) + } + if _, through := p.(*fleetping.RelayProvider); through != c.want { + t.Fatalf("%s: through the relay %v", name, through) + } + } +} diff --git a/go/cmd/ftw/fleet_ping.go b/go/cmd/ftw/fleet_ping.go index 592ebed7e..c64badd9d 100644 --- a/go/cmd/ftw/fleet_ping.go +++ b/go/cmd/ftw/fleet_ping.go @@ -6,6 +6,7 @@ import ( "path" "path/filepath" "strconv" + "strings" "sync" "time" @@ -14,6 +15,7 @@ import ( "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/fleetping" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/statsrelay" ) // The anonymous fleet ping, wired to the box. @@ -62,6 +64,7 @@ func startFleetPing( cfg *config.Config, cfgMu *sync.RWMutex, st *state.Store, + answer statsAnswer, version string, managedDriverDir string, ) (*fleetping.Pinger, error) { @@ -69,7 +72,7 @@ func startFleetPing( endpoint := cfg.FleetPing.Resolved() cfgMu.RUnlock() - provider, err := fleetping.NewHTTPProvider(endpoint, nil) + provider, err := fleetProvider(endpoint, statsrelay.Pinned()) if err != nil { return nil, err } @@ -77,7 +80,7 @@ func startFleetPing( installedAt := firstSeen(st, time.Now()) pinger, err := fleetping.New(fleetping.Options{ - Enabled: func() bool { return fleetPingEnabled(cfg, cfgMu)() && cloudlink.StatisticsChoice(st) }, + Enabled: fleetReportAllowed(answer, endpoint, provider), Facts: func() fleetping.Facts { // Drivers can be installed without restarting FTW. Read the signed // catalogue at the same time as the config so tomorrow's report and @@ -97,7 +100,78 @@ func startFleetPing( return pinger, nil } -// fleetPingEnabled reads the switch, under the config lock, at each send. +// fleetProvider sends the report to FTW Cloud through the relay when this +// build names one, and only then: FTW Cloud then never sees the box's +// address. A build without a relay posts straight to /v1/fleet as before. An +// endpoint the household chose is its own collector and is posted to +// directly. +func fleetProvider(endpoint string, relay *statsrelay.Client) (fleetping.Provider, error) { + if relay != nil && strings.TrimSpace(endpoint) == config.DefaultFleetPingEndpoint { + return fleetping.NewRelayProvider(relay), nil + } + return fleetping.NewHTTPProvider(endpoint, nil) +} + +// statsAnswer is the one answer for both anonymous daily reports, the fleet +// report and the energy report: part of FTW Cloud, and on for a box without +// it until the owner turns it off. One reader, so the two reports cannot come +// to read different answers. It asks the running Cloud link, which counts a +// grant only with the key it was made with; with no link, the box's own +// answer alone. fleet_ping.enabled is no longer a second switch; +// migrateStatistics read it once. +type statsAnswer struct { + st *state.Store + link *cloudlink.Link +} + +func (a statsAnswer) shares() bool { + if a.link != nil { + return a.link.SharesStatistics() + } + return a.st != nil && cloudlink.SharesStatistics(a.st) +} + +func (a statsAnswer) saidYes() bool { + if a.link != nil { + return a.link.SaidYesToStatistics() + } + return a.st != nil && cloudlink.SaidYesToStatistics(a.st) +} + +// fleetReportAllowed is the one answer, with one more rule for the direct +// path (Fredrik, 24 October 2026): a box that shares only by default, never +// having said yes, posts nothing straight to FTW Cloud, which would see its +// address; it waits for the relay. Through the relay, or to a collector the +// household chose, the answer alone decides. +func fleetReportAllowed(answer statsAnswer, endpoint string, provider fleetping.Provider) func() bool { + _, relayed := provider.(*fleetping.RelayProvider) + direct := !relayed && strings.TrimSpace(endpoint) == config.DefaultFleetPingEndpoint + return func() bool { + return answer.shares() && (!direct || answer.saidYes()) + } +} + +// migrateStatistics turns what an earlier release stored into the one +// answer, once: a fleet report switched off in the config stays off. Then +// the Cloud grant follows it, so an answer that came out off also stops an +// hourly upload the grant still carried. +func migrateStatistics(cfg *config.Config, cfgMu *sync.RWMutex, st *state.Store, link *cloudlink.Link) { + if st == nil { + return + } + if err := cloudlink.MigrateStatistics(st, !fleetPingEnabled(cfg, cfgMu)()); err != nil { + slog.Warn("could not record the statistics answer; nothing is sent until it is", "err", err) + return + } + if link != nil { + if err := link.ReconcileStatistics("statistics answer"); err != nil { + slog.Warn("could not make the FTW Cloud grant follow the statistics answer", "err", err) + } + } +} + +// fleetPingEnabled reads the old switch under the config lock. Since the one +// statistics answer it is read once, by migrateStatistics. // // On() and not a second reading of nil. A config posted to /api/config never // passes through applyDefaults and configreload.Apply swaps it in whole, so a diff --git a/go/cmd/ftw/fleet_ping_test.go b/go/cmd/ftw/fleet_ping_test.go index 142136992..f8c323bf2 100644 --- a/go/cmd/ftw/fleet_ping_test.go +++ b/go/cmd/ftw/fleet_ping_test.go @@ -117,7 +117,7 @@ func TestRunningFleetPingSeesADriverInstalledLater(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - pinger, err := startFleetPing(ctx, cfg, cfgMu, store, "v1.4.0", active) + pinger, err := startFleetPing(ctx, cfg, cfgMu, store, statsAnswer{st: store}, "v1.4.0", active) if err != nil { t.Fatalf("startFleetPing: %v", err) } @@ -339,7 +339,7 @@ func TestNoNameTheHouseholdChoseReachesThePostedPing(t *testing.T) { // path unchanged. Payload() is what sendOnce marshals and posts. ctx, cancel := context.WithCancel(context.Background()) cancel() - pinger, err := startFleetPing(ctx, cfg, cfgMu, store, "v1.4.0", active) + pinger, err := startFleetPing(ctx, cfg, cfgMu, store, statsAnswer{st: store}, "v1.4.0", active) if err != nil { t.Fatalf("startFleetPing: %v", err) } @@ -469,7 +469,7 @@ func TestARepositoryRootInsideTheBundledTreeIsNotTheShippedCatalogue(t *testing. ctx, cancel := context.WithCancel(context.Background()) cancel() - pinger, err := startFleetPing(ctx, cfg, cfgMu, store, "v1.4.0", active) + pinger, err := startFleetPing(ctx, cfg, cfgMu, store, statsAnswer{st: store}, "v1.4.0", active) if err != nil { t.Fatalf("startFleetPing: %v", err) } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index fb707f741..e77754b41 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2492,12 +2492,17 @@ func main() { // The bundled drivers dir is deliberately not passed: which drivers this // build ships with is compiled in, not discovered on disk. See // fleetCatalogue for what that buys. + migrateStatistics(cfg, cfgMu, st, cloudLink) + answer := statsAnswer{st: st, link: cloudLink} fleetPinger, fleetPingErr := startFleetPing( - ctx, cfg, cfgMu, st, Version, config.ManagedDriversDirOverride, + ctx, cfg, cfgMu, st, answer, Version, config.ManagedDriversDirOverride, ) if fleetPingErr != nil { slog.Warn("fleet ping unavailable", "err", fleetPingErr) } + // The anonymous daily energy report, under the same answer, through an + // independent relay so that FTW Cloud never learns which box sent it. + energyReport := startEnergyReport(ctx, cfg, cfgMu, st, answer) mutationPolicy := apiMutationPolicy() mutationPolicy.LANAuthEnabled = func() bool { @@ -2520,6 +2525,7 @@ func main() { AppEnroll: appEnrollForAPI(appEnroll, appUplink, st, appLinkEnabled, agents), Agents: agentsForAPI(agents), FleetPing: fleetPinger, + EnergyReport: energyReport, Cloud: cloudForAPI(cloudLink), CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath, ConfigApplier: applyConfigChange, diff --git a/go/internal/api/api.go b/go/internal/api/api.go index d9e2a9b4b..ecc4b96a1 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -41,6 +41,7 @@ import ( "github.com/srcfl/ftw/go/internal/coverage" "github.com/srcfl/ftw/go/internal/driverrepo" "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/energyreport" "github.com/srcfl/ftw/go/internal/evcloud" "github.com/srcfl/ftw/go/internal/events" "github.com/srcfl/ftw/go/internal/fleetping" @@ -115,6 +116,10 @@ type Deps struct { // embeddings; GET /api/fleet-ping then says so. FleetPing *fleetping.Pinger + // EnergyReport is the anonymous daily energy report, held for the same + // reason: Settings shows the report it would send. Nil leaves it out. + EnergyReport *energyreport.Sender + // Cloud is the box's FTW Cloud grant and uplink. Nil in tests and // minimal embeddings; the /api/cloud routes then say it is unavailable. Cloud CloudGrant diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go index 331d9a3bc..2d7b86126 100644 --- a/go/internal/api/api_cloud.go +++ b/go/internal/api/api_cloud.go @@ -189,7 +189,7 @@ func (s *Server) handleCloudStats(w http.ResponseWriter, r *http.Request) { 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): + case errors.Is(err, cloudlink.ErrNotGranted), errors.Is(err, cloudlink.ErrPurgePending), errors.Is(err, cloudlink.ErrCloudTermsFirst): writeAppLinkError(w, http.StatusConflict, err.Error()) case errors.Is(err, cloudlink.ErrNoService): writeAppLinkError(w, http.StatusServiceUnavailable, err.Error()) diff --git a/go/internal/api/api_cloud_test.go b/go/internal/api/api_cloud_test.go index e4c064bc1..1f37153de 100644 --- a/go/internal/api/api_cloud_test.go +++ b/go/internal/api/api_cloud_test.go @@ -430,20 +430,28 @@ func TestTheConsentRoundTripShowsThePurposes(t *testing.T) { return st } + current := `{"policy":"` + cloudwire.StatsPolicy + `"}` call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusConflict) call(s.handleCloudGrant, http.MethodPost, "/api/cloud/grant", grantBody, http.StatusOK) - call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusOK) - if st := call(s.handleCloudStatus, http.MethodGet, "/api/cloud", "", http.StatusOK); st.State != "on" || - strings.Join(st.Purposes, ",") != "serve,stats" { - t.Fatalf("status with statistics on: %+v", st) + // Older terms did not include statistics: the current ones come first. + if st := call(s.handleCloudStatus, http.MethodGet, "/api/cloud", "", http.StatusOK); !st.StatisticsNeedTerms || st.Statistics { + t.Fatalf("status under older terms: %+v", st) } - if st := call(s.handleCloudStopStats, http.MethodDelete, "/api/cloud/stats", "", http.StatusOK); strings.Join(st.Purposes, ",") != "serve" { - t.Fatalf("status with statistics off: %+v", st) + call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", current, http.StatusConflict) + call(s.handleCloudGrant, http.MethodPost, "/api/cloud/grant", `{"scopes":["ftw.live.read"],"policy":"`+cloudwire.CloudTermsPolicy+`"}`, http.StatusOK) + if st := call(s.handleCloudStatus, http.MethodGet, "/api/cloud", "", http.StatusOK); st.State != "on" || + strings.Join(st.Purposes, ",") != "serve,stats" || !st.Statistics || !st.StatisticsRequired { + t.Fatalf("status under the current terms: %+v", st) } + call(s.handleCloudStopStats, http.MethodDelete, "/api/cloud/stats", "", http.StatusConflict) if st := call(s.handleCloudRevoke, http.MethodDelete, "/api/cloud/grant", "", http.StatusOK); st.State != "revoking" { t.Fatalf("status after FTW Cloud off: %+v", st) } + // While the copy is deleted, the box's own answer can still change. call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", statsBody, http.StatusConflict) + if st := call(s.handleCloudStats, http.MethodPost, "/api/cloud/stats", current, http.StatusOK); !st.Statistics { + t.Fatalf("status after the local yes: %+v", st) + } } type cloudSiteStub struct{} diff --git a/go/internal/api/api_fleet_ping.go b/go/internal/api/api_fleet_ping.go index cc203a6d2..a07e3fb72 100644 --- a/go/internal/api/api_fleet_ping.go +++ b/go/internal/api/api_fleet_ping.go @@ -3,7 +3,9 @@ package api import ( "net/http" + "github.com/srcfl/ftw/go/internal/cloudlink" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/energyreport" ) // What this box would add to FTW's daily fleet totals. @@ -19,8 +21,18 @@ type fleetPingView struct { // FTWRelay lets Settings state FTW's retention rules only when the running // process is using the endpoint those rules govern. FTWRelay bool `json:"ftw_relay"` + // ThroughRelay is true when the report goes to FTW Cloud through the + // Oblivious HTTP relay, so FTW Cloud never sees the box's address. + ThroughRelay bool `json:"through_relay"` + // WaitsForRelay is a box that shares by default, never having said yes: + // its report goes to FTW Cloud only through the relay, and this build + // names none, so it waits. + WaitsForRelay bool `json:"waits_for_relay"` // Payload is the whole message, exactly as it would be posted. Payload any `json:"payload"` + // Energy is the daily energy report under the same answer, built by the + // call its sender uses. Absent on a box without one. + Energy *energyreport.Preview `json:"energy,omitempty"` } func (s *Server) handleFleetPing(w http.ResponseWriter, r *http.Request) { @@ -31,18 +43,29 @@ func (s *Server) handleFleetPing(w http.ResponseWriter, r *http.Request) { return } - // The switch is live, but the endpoint is fixed when the pinger starts. - // Report that active endpoint rather than a newly saved value that needs a - // restart before it can be used. - s.deps.CfgMu.RLock() - enabled := s.deps.Cfg.FleetPing.On() - s.deps.CfgMu.RUnlock() + // Enabled is whether the next report goes, read the way the sender reads + // it: the box's one statistics answer, and no direct post to FTW Cloud + // from a box that never said yes. The endpoint is fixed when the pinger + // starts. Report that active endpoint rather than a newly saved value + // that needs a restart before it can be used. + enabled := s.deps.FleetPing.Enabled() endpoint := s.deps.FleetPing.Endpoint() - writeJSON(w, http.StatusOK, fleetPingView{ - Enabled: enabled, - Endpoint: endpoint, - FTWRelay: endpoint == config.DefaultFleetPingEndpoint, - Payload: s.deps.FleetPing.Payload(), - }) + view := fleetPingView{ + Enabled: enabled, + Endpoint: endpoint, + FTWRelay: endpoint == config.DefaultFleetPingEndpoint || s.deps.FleetPing.ThroughRelay(), + ThroughRelay: s.deps.FleetPing.ThroughRelay(), + Payload: s.deps.FleetPing.Payload(), + } + shares := s.deps.State != nil && cloudlink.SharesStatistics(s.deps.State) + if s.deps.Cloud != nil { + shares = s.deps.Cloud.Status().Statistics + } + view.WaitsForRelay = !enabled && !view.ThroughRelay && endpoint == config.DefaultFleetPingEndpoint && shares + if s.deps.EnergyReport != nil { + p := s.deps.EnergyReport.Preview(r.Context()) + view.Energy = &p + } + writeJSON(w, http.StatusOK, view) } diff --git a/go/internal/api/api_fleet_ping_test.go b/go/internal/api/api_fleet_ping_test.go index 306137d5b..ed543683d 100644 --- a/go/internal/api/api_fleet_ping_test.go +++ b/go/internal/api/api_fleet_ping_test.go @@ -5,12 +5,16 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "sync" "testing" "time" + "github.com/srcfl/ftw/go/internal/cloudlink" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/energyreport" "github.com/srcfl/ftw/go/internal/fleetping" + "github.com/srcfl/ftw/go/internal/state" ) type silentProvider string @@ -24,7 +28,13 @@ func fleetPingServer(t *testing.T, cfg *config.Config) *Server { func fleetPingServerAt(t *testing.T, cfg *config.Config, endpoint string) *Server { t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) pinger, err := fleetping.New(fleetping.Options{ + Enabled: func() bool { return cloudlink.SharesStatistics(st) }, Provider: silentProvider(endpoint), Facts: func() fleetping.Facts { return fleetping.FactsFromConfig(cfg, "v1.4.0", @@ -35,7 +45,12 @@ func fleetPingServerAt(t *testing.T, cfg *config.Config, endpoint string) *Serve if err != nil { t.Fatalf("fleetping.New: %v", err) } - return New(&Deps{CfgMu: &sync.RWMutex{}, Cfg: cfg, FleetPing: pinger}) + // The box's one statistics answer, written from this config the way + // Core writes it once at start. + if err := cloudlink.MigrateStatistics(st, !cfg.FleetPing.On()); err != nil { + t.Fatal(err) + } + return New(&Deps{CfgMu: &sync.RWMutex{}, Cfg: cfg, FleetPing: pinger, State: st}) } func TestFleetPingShowsTheRealPayload(t *testing.T) { @@ -149,3 +164,50 @@ func TestFleetPingSaysSoWhenItIsNotRunning(t *testing.T) { t.Fatalf("status %d, want 503", w.Code) } } + +// dayLedger is one site meter that recorded every bucket of every day. +type dayLedger struct{} + +func (dayLedger) EnergyAssets() ([]state.EnergyAsset, error) { + return []state.EnergyAsset{{AssetID: "m/grid_meter", Kind: state.AssetGridMeter, FirstSeenMS: 0, LastSeenMS: 1 << 62}}, nil +} + +func (dayLedger) LoadEnergyHistoryContext(_ context.Context, q state.EnergyHistoryQuery) ([]state.EnergyLedgerPoint, bool, error) { + var out []state.EnergyLedgerPoint + for at := q.SinceMS; at < q.UntilMS; at += state.EnergyLedgerBucketMS { + out = append(out, state.EnergyLedgerPoint{Flow: state.FlowGridImport, BucketStartMS: at, BucketLenMS: state.EnergyLedgerBucketMS, EnergyWh: 50, Quality: "measured"}) + } + return out, false, nil +} + +// The daily energy report rides under the same switch, so Settings shows it +// beside the fleet report, built by the call its sender uses. A build with no +// relay says it sends nothing. +func TestFleetPingShowsTheEnergyReportItWouldSend(t *testing.T) { + cfg := &config.Config{FleetPing: &config.FleetPing{Enabled: true}} + srv := fleetPingServer(t, cfg) + sender, err := energyreport.New(energyreport.Options{Ledger: dayLedger{}, Enabled: func() bool { return true }, + Now: func() time.Time { return time.Date(2026, 10, 24, 9, 0, 0, 0, time.UTC) }}) + if err != nil { + t.Fatal(err) + } + srv.deps.EnergyReport = sender + + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/fleet-ping", nil)) + var got struct { + Energy struct { + Relay string `json:"relay"` + Sending bool `json:"sending"` + Day string `json:"day"` + Payload map[string]any `json:"payload"` + } `json:"energy"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + e := got.Energy + if e.Relay != "" || e.Sending || e.Day != "2026-10-23" || e.Payload["grid_import_kwh"] != 14.0 || e.Payload["date"] != "2026-10-23" || len(e.Payload) != 8 { + t.Fatalf("energy = %+v", e) + } +} diff --git a/go/internal/api/cloud_statistics.go b/go/internal/api/cloud_statistics.go index 5f47fea75..3769f2d63 100644 --- a/go/internal/api/cloud_statistics.go +++ b/go/internal/api/cloud_statistics.go @@ -140,6 +140,6 @@ func (s *Server) handleCloudStatsPolicy(w http.ResponseWriter, r *http.Request) "retention": "48 hours. Leaving Cloud or stopping local sharing deletes unpublished household rows; published group totals remain.", "publication_minimum_homes": cloudwire.StatsMinimumHomes, "publication_max_home_share": 0.3, - "controller": "sourceful", "local_default": "ask_yes", "local_optional": true, "cloud_required": true, + "controller": "sourceful", "local_default": "on", "local_optional": true, "cloud_required": true, }) } diff --git a/go/internal/cloudlink/choice.go b/go/internal/cloudlink/choice.go index 776a265a7..4137df789 100644 --- a/go/internal/cloudlink/choice.go +++ b/go/internal/cloudlink/choice.go @@ -2,11 +2,17 @@ package cloudlink import ( "errors" + "slices" + "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" ) -var ErrCloudNeedsStatistics = errors.New("cloudlink: statistics are part of FTW Cloud; turn FTW Cloud off to stop contributing") +var ErrCloudNeedsStatistics = errors.New("cloudlink: statistics are part of FTW Cloud; turn FTW Cloud off, then statistics, to stop contributing") + +// ErrCloudTermsFirst is statistics asked for while Cloud runs under older +// terms, which did not include them. +var ErrCloudTermsFirst = errors.New("cloudlink: accept the current FTW Cloud terms first; they include statistics") // includeStatistics records the exact scope the owner read. Service approval // still gates every report. The caller holds mu. @@ -34,36 +40,6 @@ func (l *Link) enableLinkChoice() error { return nil } -// startStatisticsOnly reuses the signed uplink, without publishing any phone, -// pairing code, Noise key, live reading or grant for remote access. -func (l *Link) startStatisticsOnly(req StatsRequest) error { - if err := l.linkLocked(); err != nil { - return err - } - if l.link.Ending { - return ErrLinkEnding - } - id, err := randomBytes(cloudwire.GrantIDBytes) - if err != nil { - return err - } - key, err := randomBytes(cloudwire.GrantKeyBytes) - if err != nil { - return err - } - g := grantRecord{ID: id, GrantKey: key, KeyFp: l.link.KeyFp, Policy: req.Policy, By: req.By, Controller: cloudwire.ControllerSourceful, Scopes: []string{}, Purposes: []string{protocol.CloudPurposeStats}} - if l.clockSynced() { - g.AtMs = l.opts.Now().UnixMilli() - } - if err := l.includeStatistics(&g, true); err != nil { - return err - } - if err := l.enableLinkChoice(); err != nil { - return err - } - return l.storeLocked(g, "local statistics accepted") -} - // End stops all Cloud functions, purges the home and remembers the choice so // a paired phone or a restart cannot silently turn the link back on. func (l *Link) End(by string) error { @@ -103,17 +79,126 @@ func (l *Link) End(by string) error { return nil } -// StatisticsChoice is the saved answer for both project reports. The daily -// report must not turn itself on before the owner answers the new question. -func StatisticsChoice(kv KV) bool { +// SharesStatistics is the one statistics answer, for both anonymous daily +// reports: the fleet report and the energy report (Fredrik, 24 October 2026). +// With FTW Cloud they are part of Cloud, with no separate off switch. A Cloud +// grant under older terms sends nothing until the owner accepts the current +// ones. Without Cloud they are on unless the owner turned them off. +// +// This form is for a box whose Cloud link is not running, so no stored grant +// is honoured: the local answer alone. Link.SharesStatistics counts the grant +// the running link honours, which it does only with the key it was made with. +func SharesStatistics(kv KV) bool { if kv == nil { return false } - if disabled, _ := kv.LoadConfig(disabledKey); disabled == "true" { + share, _ := kv.LoadConfig(statsShareKey) + return sharesStatistics(nil, share) +} + +// SaidYesToStatistics reports an active yes, for a box whose Cloud link is +// not running: a local yes. A box that shares only by default has not given +// one, so its reports reach FTW Cloud only through the relay, which hides its +// address; nothing goes straight to FTW Cloud from it. +func SaidYesToStatistics(kv KV) bool { + if kv == nil { return false } + share, _ := kv.LoadConfig(statsShareKey) + return saidYesToStatistics(nil, share) +} + +// SharesStatistics is the one answer with the grant this link honours. +func (l *Link) SharesStatistics() bool { + g, share := l.statisticsAnswer() + return sharesStatistics(g, share) +} + +// SaidYesToStatistics is an active yes with the grant this link honours: +// Cloud under the current terms, or a local yes. +func (l *Link) SaidYesToStatistics() bool { + g, share := l.statisticsAnswer() + return saidYesToStatistics(g, share) +} + +// statisticsAnswer is the grant this link honours, nil once Cloud is turned +// off, and the stored local answer. +func (l *Link) statisticsAnswer() (*grantRecord, string) { + l.mu.Lock() + g := l.grant + if l.disabled { + g = nil + } + l.mu.Unlock() + share, _ := l.opts.KV.LoadConfig(statsShareKey) + return g, share +} + +func sharesStatistics(g *grantRecord, share string) bool { + if g != nil && !g.Revoked && !g.statisticsOnly() { + return g.Policy == cloudwire.CloudTermsPolicy + } + return share == "on" || share == "default" +} + +func saidYesToStatistics(g *grantRecord, share string) bool { + if g != nil && !g.Revoked && !g.statisticsOnly() { + return g.Policy == cloudwire.CloudTermsPolicy + } + return share == "on" +} + +// cloudUnderOlderTerms is Cloud on under terms older than the current ones, +// which did not include statistics: nothing goes until the owner accepts. +func cloudUnderOlderTerms(g *grantRecord) bool { + return g != nil && !g.Revoked && !g.statisticsOnly() && g.Policy != cloudwire.CloudTermsPolicy +} + +// ReconcileStatistics makes the grant follow the one answer once it is off: +// the stats purpose and statsId go, and a statistics-only link ends, as +// turning statistics off does. Core calls it after MigrateStatistics, so an +// earlier fleet switch set to off also stops the hourly upload it never +// covered. +func (l *Link) ReconcileStatistics(by string) error { + g, share := l.statisticsAnswer() + if share != "off" || g == nil || g.Revoked || cloudRequiresStatistics(g) || + (len(g.StatsID) == 0 && !slices.Contains(g.Purposes, protocol.CloudPurposeStats)) { + return nil + } + return l.StopStats(by) +} + +// cloudRequiresStatistics reports whether the grant is Cloud under the +// current terms, where statistics have no separate off switch. +func cloudRequiresStatistics(g *grantRecord) bool { + return g != nil && !g.Revoked && g.Policy == cloudwire.CloudTermsPolicy +} + +// MigrateStatistics writes the one answer once, from what an earlier release +// stored. A local No, a fleet report switched off, or FTW Cloud turned off +// (whose screen said that stops statistics) stays off. A local yes stays yes. +// Every other box is on by default: a box without Cloud shares unless its +// owner turns it off. Until this has run, SharesStatistics reads a box +// without Cloud as off. +func MigrateStatistics(kv KV, fleetReportOff bool) error { + if v, ok := kv.LoadConfig(statsShareKey); ok && (v == "on" || v == "off" || v == "default") { + return nil + } g, err := loadJSON[grantRecord](kv, grantKey) - return err == nil && g != nil && !g.Revoked && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 + if err != nil { + return err + } + decision, _ := kv.LoadConfig(statsDecisionKey) + disabled, _ := kv.LoadConfig(disabledKey) + answeredYes := g != nil && !g.Revoked && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 + share := "default" + switch { + case fleetReportOff || disabled == "true" || (decision != "" && !answeredYes): + share = "off" + case answeredYes && g.statisticsOnly(): + share = "on" + } + return kv.SaveConfig(statsShareKey, share) } // An unset clock cannot place a choice in UTC. Start its upload window only diff --git a/go/internal/cloudlink/choice_test.go b/go/internal/cloudlink/choice_test.go index be516aa8b..1fae0a3b3 100644 --- a/go/internal/cloudlink/choice_test.go +++ b/go/internal/cloudlink/choice_test.go @@ -24,44 +24,34 @@ func TestCloudTermsIncludeStatisticsAndCannotOptOutSeparately(t *testing.T) { if err := h.link.StopStats("owner"); !errors.Is(err, ErrCloudNeedsStatistics) { t.Fatal("Cloud could opt out independently", err) } - if !h.link.Status().CloudTermsAccepted || !h.link.Status().StatisticsActive { + if s := h.link.Status(); !s.CloudTermsAccepted || !s.StatisticsActive || !s.Statistics || !s.StatisticsRequired { t.Fatal("new terms not active") } } -func TestLocalStatisticsPublishesNoRemoteAccess(t *testing.T) { +// Without Cloud, statistics are an answer on the box. Turning them on or off +// links nothing to Cloud and publishes no remote access. +func TestLocalStatisticsLinkNothingToCloud(t *testing.T) { svc := newFakeService(t) h := newHarness(t, svc) + if s := h.link.Status(); s.StatisticsDecisionRequired || s.Statistics || s.StatisticsRequired { + t.Fatal("a box asked, or shared before its answer was recorded", s) + } if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy, By: "local owner"}); err != nil { t.Fatal(err) } - r := h.nextRoster(t) - if len(r.Devices) != 0 || len(r.Pending) != 0 || len(r.Noise) != 0 || len(r.WebPush) != 0 || len(r.Grant.Scopes) != 0 || len(r.Grant.Purposes) != 1 || r.Grant.Purposes[0] != protocol.CloudPurposeStats { - t.Fatal("statistics-only exposed Cloud access", r) + s := h.link.Status() + if !s.Statistics || s.StatisticsRequired || s.Link != "off" || s.StatisticsOnly || !SharesStatistics(h.kv) { + t.Fatal("local statistics are not a local answer", s) } - if h.link.TakesTun() || h.link.Home() != "" || h.link.SessionInfo() != nil || h.link.current().copyOn() { + if h.link.Home() != "" || h.link.SessionInfo() != nil || h.link.current().copyOn() || h.link.current().g != nil { t.Fatal("local statistics enabled Cloud functions") } - if err := h.link.SendTun(cloudwire.Tun{}, false); !errors.Is(err, ErrNoUplink) { - t.Fatal("statistics-only queued a tunnel", err) - } - if !h.link.Status().StatisticsOnly || h.link.Status().State != "statistics" { - t.Fatal("local statistics labelled Cloud") - } -} - -func TestDecliningLocalStatisticsRecordsTheAnswerWithoutCreatingALink(t *testing.T) { - svc := newFakeService(t) - h := newHarness(t, svc) - if !h.link.Status().StatisticsDecisionRequired { - t.Fatal("an unanswered choice looked accepted") - } if err := h.link.StopStats("local owner"); err != nil { t.Fatal(err) } - s := h.link.Status() - if s.StatisticsDecisionRequired || s.Link != "off" || s.StatisticsActive { - t.Fatal("decline did not stay local", s) + if s := h.link.Status(); s.Statistics || s.Link != "off" || s.StatisticsActive || SharesStatistics(h.kv) { + t.Fatal("off did not stay off and local", s) } } @@ -83,31 +73,70 @@ func TestEndingCloudPreventsAutomaticRelinkFromPairedPhones(t *testing.T) { } } -func TestDailyReportRequiresCurrentAnswerAndHonoursDurableOff(t *testing.T) { - kv := &memKV{} - if StatisticsChoice(kv) { - t.Fatal("unanswered box may send") - } - g := grantRecord{StatsPolicy: "ftw-project-stats-2026-10-v1", StatsID: make([]byte, 16)} - if err := saveJSON(kv, grantKey, g); err != nil { - t.Fatal(err) - } - if StatisticsChoice(kv) { - t.Fatal("old choice may send new report") - } - g.StatsPolicy = cloudwire.StatsPolicy - if err := saveJSON(kv, grantKey, g); err != nil { - t.Fatal(err) - } - if !StatisticsChoice(kv) { - t.Fatal("current answer may not send") +// Every earlier No stays no; every other box shares, since a box without +// Cloud shares until its owner turns it off. The answer is written once. +func TestMigrationKeepsEveryEarlierNo(t *testing.T) { + yes := grantRecord{StatsPolicy: cloudwire.StatsPolicy, StatsID: make([]byte, 16), Purposes: []string{protocol.CloudPurposeStats}} + for name, c := range map[string]struct { + kv map[string]string + grant *grantRecord + fleetOff bool + want bool + }{ + "never asked": {want: true}, + "local yes": {grant: &yes, want: true}, + "local no": {kv: map[string]string{statsDecisionKey: cloudwire.StatsPolicy}, want: false}, + "no to an older policy": {kv: map[string]string{statsDecisionKey: "ftw-project-stats-2026-10-v1"}, want: false}, + "Cloud turned off": {kv: map[string]string{disabledKey: "true"}, want: false}, + "fleet report off": {fleetOff: true, want: false}, + "yes, fleet report off": {grant: &yes, fleetOff: true, want: false}, + "already answered: on": {kv: map[string]string{statsShareKey: "on", disabledKey: "true"}, want: true}, + "already on by default": {kv: map[string]string{statsShareKey: "default"}, want: true}, + "already answered: off": {kv: map[string]string{statsShareKey: "off"}, want: false}, + } { + kv := &memKV{m: map[string]string{}} + for k, v := range c.kv { + kv.m[k] = v + } + if c.grant != nil { + if err := saveJSON(kv, grantKey, *c.grant); err != nil { + t.Fatal(err) + } + } + if SharesStatistics(kv) && c.kv[statsShareKey] == "" { + t.Fatalf("%s: shared before the answer was written", name) + } + if err := MigrateStatistics(kv, c.fleetOff); err != nil { + t.Fatal(err) + } + if got := SharesStatistics(kv); got != c.want { + t.Fatalf("%s: shares %v, want %v", name, got, c.want) + } + // Only a yes that was given counts as one. + if said := SaidYesToStatistics(kv); said != (c.want && (c.grant != nil || c.kv[statsShareKey] == "on")) { + t.Fatalf("%s: said yes %v", name, said) + } + // Written once: a later start reads the answer, not the old flags. + if err := MigrateStatistics(kv, !c.fleetOff); err != nil || SharesStatistics(kv) != c.want { + t.Fatalf("%s: a second start changed the answer", name) + } } - // This flag is saved first, even if writing a later purge record fails. - if err := kv.SaveConfig(disabledKey, "true"); err != nil { +} + +// Without a running link no stored grant counts, so the box's own answer +// alone decides. The link's answer, with the grant it honours, is tested in +// link_test.go. +func TestWithoutALinkOnlyTheLocalAnswerCounts(t *testing.T) { + kv := &memKV{m: map[string]string{statsShareKey: "off"}} + cloud := grantRecord{Policy: cloudwire.CloudTermsPolicy, Purposes: []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}} + if err := saveJSON(kv, grantKey, cloud); err != nil { t.Fatal(err) } - if StatisticsChoice(kv) { - t.Fatal("durable Cloud off did not stop daily report") + for share, want := range map[string][2]bool{"off": {false, false}, "default": {true, false}, "on": {true, true}} { + kv.m[statsShareKey] = share + if got := [2]bool{SharesStatistics(kv), SaidYesToStatistics(kv)}; got != want { + t.Fatalf("%s: shares, said yes = %v, want %v", share, got, want) + } } } @@ -166,7 +195,7 @@ func TestWholeCloudOffStopsImmediatelyWhenLaterStorageFails(t *testing.T) { if err := h.link.End("owner"); err == nil { t.Fatal("storage failure was not returned") } - if h.link.TakesTun() || h.link.Home() != "" || h.link.SessionInfo() != nil || h.link.current().copyOn() || StatisticsChoice(h.kv) { + if h.link.TakesTun() || h.link.Home() != "" || h.link.SessionInfo() != nil || h.link.current().copyOn() || SharesStatistics(h.kv) { t.Fatal("a failed follow-up write left Cloud sharing active") } if disabled, _ := h.kv.LoadConfig(disabledKey); disabled != "true" { diff --git a/go/internal/cloudlink/grant.go b/go/internal/cloudlink/grant.go index 625ad0c8b..5a26160c8 100644 --- a/go/internal/cloudlink/grant.go +++ b/go/internal/cloudlink/grant.go @@ -28,6 +28,10 @@ const ( purgedKey = "cloud/purged_v1" disabledKey = "cloud/disabled_v1" statsDecisionKey = "cloud/statistics_decision_v1" + // statsShareKey is the one statistics answer of a box without FTW Cloud: + // "on" (the owner said yes), "default" (on, never answered) or "off" + // (MigrateStatistics, SharesStatistics). + statsShareKey = "statistics/share_v1" ) // linkRecord is the box's link to FTW Cloud: which cloud.key it holds. The diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 5d5cf0c03..aad8f1c4b 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -772,26 +772,36 @@ type StatsRequest struct { By string `json:"-"` } -// Stats adds the stats purpose to a grant already on. The first time, it -// draws the statsId that names this box's rows at the service; later calls -// keep it. It sends no statistics itself. +// Stats turns statistics on. For a box without FTW Cloud that is the one +// answer on the box, and nothing links to Cloud. Under a grant already on it +// also adds the stats purpose; the first time, it draws the statsId that +// names this box's rows at the service, and later calls keep it. It sends no +// statistics itself. func (l *Link) Stats(req StatsRequest) error { - if len(l.opts.ServiceKeys) == 0 { - return ErrNoService - } if strings.TrimSpace(req.Policy) == "" { return fmt.Errorf("%w: name the policy the owner accepted", ErrGrantRequest) } l.mu.Lock() defer l.mu.Unlock() switch { - case l.grant == nil: + case l.grant == nil || l.grant.Revoked || l.disabled: + // The box's own answer, which needs no grant: also while a Cloud + // that was turned off waits for its deletion receipt. if req.Policy != cloudwire.StatsPolicy { return ErrNotGranted } - return l.startStatisticsOnly(req) - case l.grant.Revoked: - return ErrPurgePending + if err := l.opts.KV.SaveConfig(statsShareKey, "on"); err != nil { + return err + } + l.log.Info("audit: statistics on", "by", req.By) + return nil + case cloudUnderOlderTerms(l.grant): + return ErrCloudTermsFirst + case len(l.opts.ServiceKeys) == 0: + return ErrNoService + } + if err := l.opts.KV.SaveConfig(statsShareKey, "on"); err != nil { + return err } g := *l.grant if len(g.StatsID) == 0 || g.StatsPolicy != req.Policy { @@ -812,16 +822,21 @@ func (l *Link) Stats(req StatsRequest) error { return l.storeLocked(g, "statistics on") } -// StopStats removes the stats purpose and forgets the statsId. Turning it on -// again draws a new one. +// StopStats turns statistics off for a box without FTW Cloud, removes the +// stats purpose and forgets the statsId. Turning it on again draws a new one. +// With Cloud under the current terms statistics are part of Cloud. func (l *Link) StopStats(by string) error { l.mu.Lock() - if l.grant != nil && !l.grant.Revoked && l.grant.Policy == cloudwire.CloudTermsPolicy { + if cloudRequiresStatistics(l.grant) { l.mu.Unlock() return ErrCloudNeedsStatistics } only := l.grant != nil && l.grant.statisticsOnly() l.mu.Unlock() + if err := l.opts.KV.SaveConfig(statsShareKey, "off"); err != nil { + return err + } + l.log.Info("audit: statistics off", "by", by) if only { if err := l.End(by); err != nil { return err @@ -937,6 +952,13 @@ type Status struct { CloudTermsAccepted bool `json:"cloudTermsAccepted"` StatisticsOnly bool `json:"statisticsOnly"` StatisticsDecisionRequired bool `json:"statisticsDecisionRequired"` + // Statistics is the one answer for both anonymous daily reports, and + // StatisticsRequired says it is part of FTW Cloud, with no off switch. + Statistics bool `json:"statistics"` + StatisticsRequired bool `json:"statisticsRequired"` + // StatisticsNeedTerms is Cloud on under older terms: statistics wait + // until the owner accepts the current ones. + StatisticsNeedTerms bool `json:"statisticsNeedTerms"` // State is the readable copy's: off, on or revoking. State string `json:"state"` StatisticsPolicy string `json:"statisticsPolicy"` @@ -995,17 +1017,23 @@ func (l *Link) Status() Status { TunDropped: l.tunDropped, } s.CloudPolicy = cloudwire.CloudTermsPolicy - decision, _ := l.opts.KV.LoadConfig(statsDecisionKey) - s.StatisticsDecisionRequired = decision != cloudwire.StatsPolicy - if l.grant != nil && !l.grant.Revoked && l.grant.StatsPolicy == cloudwire.StatsPolicy && len(l.grant.StatsID) == 16 { - s.StatisticsDecisionRequired = false + // Nobody is asked any more: statistics are part of Cloud, and on for a + // box without it until its owner turns them off. The field stays false + // for clients that still read it. + share, _ := l.opts.KV.LoadConfig(statsShareKey) + g := l.grant + if l.disabled { + g = nil } + s.Statistics = sharesStatistics(g, share) + s.StatisticsRequired = cloudRequiresStatistics(g) + s.StatisticsNeedTerms = cloudUnderOlderTerms(g) s.StatisticsOnly = l.grant.statisticsOnly() s.CloudTermsAccepted = l.grant != nil && !l.grant.Revoked && l.grant.Policy == cloudwire.CloudTermsPolicy s.StatisticsPolicy = cloudwire.StatsPolicy s.StatisticsReady = l.connected && l.serviceStatsPolicy == cloudwire.StatsPolicy if g := l.grant; g != nil { - s.StatisticsActive = !g.Revoked && g.StatsAtMs > 0 && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && slices.Contains(g.Purposes, protocol.CloudPurposeStats) + s.StatisticsActive = s.Statistics && !g.Revoked && g.StatsAtMs > 0 && g.StatsPolicy == cloudwire.StatsPolicy && len(g.StatsID) == 16 && slices.Contains(g.Purposes, protocol.CloudPurposeStats) } s.BudgetReached = s.BytesLast24h >= budgetBytes if !l.lastSend.IsZero() { diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index 1fc3d2947..d0ed40611 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -1147,43 +1147,35 @@ func (h *harness) nextRoster(t *testing.T) cloudwire.Roster { return r } -// Statistics are a second consent under a grant already on. The box draws a -// statsId once, keeps it while statistics stay on, tells the service through -// the roster and sends no statistics yet. -func TestStatisticsAreASecondConsentUnderTheGrant(t *testing.T) { +// Under Cloud, statistics come with the current terms. Under older terms they +// wait: asking for them is refused rather than added beside the old terms. +func TestStatisticsComeWithTheCurrentCloudTerms(t *testing.T) { svc := newFakeService(t) h := newHarness(t, svc) - if err := h.link.Stats(StatsRequest{Policy: "stats-2026-10"}); !errors.Is(err, ErrNotGranted) { - t.Fatalf("statistics with FTW Cloud off: %v", err) - } - h.grant(t) - first := h.nextRoster(t) - svc.waitFor(cloudwire.MsgLive, 5*time.Second) if err := h.link.Stats(StatsRequest{}); !errors.Is(err, ErrGrantRequest) { t.Fatalf("statistics without a policy: %v", err) } - - if err := h.link.Stats(StatsRequest{Policy: "stats-2026-10", By: "app:aaaa1111"}); err != nil { - t.Fatal(err) - } - r := h.nextRoster(t) - if strings.Join(r.Grant.Purposes, ",") != "serve,stats" || r.RosterSeq <= first.RosterSeq { - t.Fatalf("roster after statistics on: purposes %v, seq %d after %d", r.Grant.Purposes, r.RosterSeq, first.RosterSeq) + h.grant(t) + first := h.nextRoster(t) + if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy, By: "app:aaaa1111"}); !errors.Is(err, ErrCloudTermsFirst) { + t.Fatalf("statistics under older terms: %v", err) } - if st := h.link.Status(); strings.Join(st.Purposes, ",") != "serve,stats" { - t.Fatalf("status purposes %v", st.Purposes) + if g := h.record(t); len(g.StatsID) != 0 || strings.Join(g.Purposes, ",") != "serve" { + t.Fatalf("older terms gained statistics: %+v", g) } - g := h.record(t) - if len(g.StatsID) != statsIDBytes || g.StatsPolicy != "stats-2026-10" || g.Policy != "2026-10" { - t.Fatalf("grant record %+v", g) + if st := h.link.Status(); !st.StatisticsNeedTerms || st.Statistics || h.link.SharesStatistics() { + t.Fatalf("status under older terms: %+v", st) } - // A new policy needs a new id and consent start. - if err := h.link.Stats(StatsRequest{Policy: "stats-2026-11"}); err != nil { + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: cloudwire.CloudTermsPolicy, By: "owner"}); err != nil { t.Fatal(err) } - if again := h.record(t); bytes.Equal(again.StatsID, g.StatsID) || again.StatsPolicy != "stats-2026-11" { - t.Fatalf("statsId %x became %x", g.StatsID, again.StatsID) + r := h.nextRoster(t) + if strings.Join(r.Grant.Purposes, ",") != "serve,stats" || r.RosterSeq <= first.RosterSeq || len(r.Grant.StatsID) != statsIDBytes { + t.Fatalf("roster under the current terms: %+v", r.Grant) + } + if st := h.link.Status(); !st.Statistics || !st.StatisticsRequired || st.StatisticsNeedTerms || !h.link.SaidYesToStatistics() { + t.Fatalf("status under the current terms: %+v", st) } // A restart reads it back. @@ -1192,74 +1184,83 @@ func TestStatisticsAreASecondConsentUnderTheGrant(t *testing.T) { if err != nil { t.Fatal(err) } - if st := reopened.Status(); strings.Join(st.Purposes, ",") != "serve,stats" { + if st := reopened.Status(); strings.Join(st.Purposes, ",") != "serve,stats" || !st.Statistics { t.Fatalf("after a restart: %+v", st) } - for _, env := range svc.received() { if env.T == "stats" { - t.Fatal("the box sent statistics") + t.Fatal("the box sent statistics before the service approved them") } } } -// Turning statistics off forgets the statsId; turning them on again draws a -// new one, because the service refuses an id it has deleted. -func TestStoppingStatisticsForgetsTheID(t *testing.T) { +// While a Cloud that was turned off waits for its deletion receipt, the box's +// own answer can still change. It touches nothing in the grant being deleted. +func TestTheLocalAnswerChangesWhileTheCopyIsBeingDeleted(t *testing.T) { svc := newFakeService(t) h := newHarness(t, svc) - if err := h.link.StopStats("lan"); err != nil { - t.Fatalf("statistics off with FTW Cloud off: %v", err) - } h.grant(t) - h.nextRoster(t) - if err := h.link.Stats(StatsRequest{Policy: "p"}); err != nil { - t.Fatal(err) - } - h.nextRoster(t) - old := h.record(t).StatsID - - if err := h.link.StopStats("lan"); err != nil { + if err := h.link.Revoke("test"); err != nil { t.Fatal(err) } - r := h.nextRoster(t) - if strings.Join(r.Grant.Purposes, ",") != "serve" { - t.Fatalf("roster after statistics off: %v", r.Grant.Purposes) + if err := h.link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrNotGranted) { + t.Fatalf("an unknown policy: %v", err) } - if g := h.record(t); len(g.StatsID) != 0 || g.StatsPolicy != "" || strings.Join(g.Purposes, ",") != "serve" { - t.Fatalf("grant record after statistics off: %+v", g) + if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy}); err != nil { + t.Fatalf("statistics while revoking: %v", err) } - if err := h.link.StopStats("lan"); err != nil { - t.Fatalf("statistics off twice: %v", err) + if g := h.record(t); !g.Revoked || len(g.StatsID) != 0 || !h.link.SharesStatistics() { + t.Fatalf("grant while revoking: %+v", g) } - - if err := h.link.Stats(StatsRequest{Policy: "p"}); err != nil { - t.Fatal(err) - } - if renewed := h.record(t).StatsID; len(renewed) != statsIDBytes || bytes.Equal(renewed, old) { - t.Fatalf("statsId %x after %x", renewed, old) + if err := h.link.StopStats("lan"); err != nil || h.link.SharesStatistics() { + t.Fatalf("off while revoking: %v", err) } } -func TestNoStatisticsWhileTheCopyIsBeingDeleted(t *testing.T) { +// A stored grant counts only when the link honours it. With a cloud.key that +// is not the one the grant was made with, the box's own answer stands, and a +// box that only shares by default has said no yes. +func TestOnlyAGrantTheLinkHonoursCounts(t *testing.T) { svc := newFakeService(t) h := newHarness(t, svc) - h.grant(t) - if err := h.link.Revoke("test"); err != nil { + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: cloudwire.CloudTermsPolicy, By: "owner"}); err != nil { t.Fatal(err) } - if err := h.link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrPurgePending) { - t.Fatalf("statistics while revoking: %v", err) + h.nextRoster(t) + if err := h.kv.SaveConfig(statsShareKey, "default"); err != nil { + t.Fatal(err) + } + if !h.link.SaidYesToStatistics() { + t.Fatal("the current terms are not a yes") + } + if err := os.Remove(h.keyPath); err != nil { + t.Fatal(err) + } + if _, err := createKey(h.keyPath); err != nil { + t.Fatal(err) + } + reopened, err := New(Options{Site: h.site, Roster: fakeRoster{}, KV: h.kv, KeyPath: h.keyPath, ServiceKeys: h.link.opts.ServiceKeys}) + if err != nil { + t.Fatal(err) + } + if reopened.Status().State != "off" || reopened.SaidYesToStatistics() || !reopened.SharesStatistics() { + t.Fatalf("a grant made with another key counted: %+v", reopened.Status()) } } -func TestABuildWithoutAServiceRefusesStatistics(t *testing.T) { - link, err := New(Options{Site: &fakeSite{}, Roster: fakeRoster{}, KV: &memKV{}, +// Without Cloud the statistics answer is the box's own, so a build with no +// service still records it. It still names the policy the owner read. +func TestABuildWithoutAServiceStillRecordsTheLocalAnswer(t *testing.T) { + kv := &memKV{} + link, err := New(Options{Site: &fakeSite{}, Roster: fakeRoster{}, KV: kv, KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: []*ecdsa.PublicKey{}}) if err != nil { t.Fatal(err) } - if err := link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrNoService) { + if err := link.Stats(StatsRequest{Policy: "p"}); !errors.Is(err, ErrNotGranted) { + t.Fatalf("an unknown policy: %v", err) + } + if err := link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy}); err != nil || !SharesStatistics(kv) { t.Fatalf("statistics: %v", err) } } diff --git a/go/internal/cloudlink/stats.go b/go/internal/cloudlink/stats.go index 7edd28e12..88aabd9c2 100644 --- a/go/internal/cloudlink/stats.go +++ b/go/internal/cloudlink/stats.go @@ -28,6 +28,13 @@ func (w *writer) statistics(snap grantSnapshot, st *cadence, now time.Time) erro !slices.Contains(g.Purposes, protocol.CloudPurposeStats) || !w.link.clockSynced() { return nil } + // The one answer covers this upload too, and only a given yes sends an + // hour: Cloud under the current terms or a local yes. Off, a box that + // shares only by default, or Cloud under older terms sends none, + // whatever the grant still carries. + if share, _ := w.link.opts.KV.LoadConfig(statsShareKey); !saidYesToStatistics(g, share) { + return nil + } hour := now.UTC().Truncate(time.Hour).Add(-time.Hour).UnixMilli() if hour < g.StatsAtMs || st.statsHour == hour || now.Sub(st.statsCheckedAt) < time.Minute { return nil diff --git a/go/internal/cloudlink/stats_test.go b/go/internal/cloudlink/stats_test.go index 80b784e74..9bf5ddffb 100644 --- a/go/internal/cloudlink/stats_test.go +++ b/go/internal/cloudlink/stats_test.go @@ -3,9 +3,12 @@ package cloudlink import ( "context" "crypto/ecdsa" - "github.com/srcfl/ftw/go/protocol/cloudwire" + "errors" "testing" "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) type statsSite struct{ *fakeSite } @@ -13,25 +16,54 @@ type statsSite struct{ *fakeSite } func (s statsSite) Statistics(_ context.Context, now time.Time) (cloudwire.Stats, bool) { return cloudwire.Stats{HourMS: now.UTC().Truncate(time.Hour).Add(-time.Hour).UnixMilli(), Metrics: map[string]int64{"pv_generation_wh": 120}}, true } -func TestStatisticsSendsSignedClosedHourOnlyWithCurrentConsent(t *testing.T) { + +func statsHarness(t *testing.T, approved bool) (*fakeService, *harness) { svc := newFakeService(t) - svc.statsPolicy = cloudwire.StatsPolicy + if approved { + svc.statsPolicy = cloudwire.StatsPolicy + } h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { o.Site = statsSite{&fakeSite{}} o.Now = func() time.Time { return time.Now().Add(-2 * time.Hour) } }) + return svc, h +} + +func sentStats(svc *fakeService) bool { + for _, e := range svc.received() { + if e.T == cloudwire.MsgStats { + return true + } + } + return false +} + +// The hourly upload goes only under the current Cloud terms. Asking for it +// under older terms is refused, and a grant from before this rule that still +// carries the stats purpose under older terms sends nothing either. +func TestStatisticsSendsSignedClosedHourOnlyWithCurrentConsent(t *testing.T) { + svc, h := statsHarness(t, true) h.grant(t) h.nextRoster(t) - if err := h.link.Stats(StatsRequest{Policy: "old-policy"}); err != nil { + if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy}); !errors.Is(err, ErrCloudTermsFirst) { + t.Fatalf("statistics under older terms: %v", err) + } + // As an earlier release could leave it: older terms with the stats purpose. + g := h.record(t) + g.Purposes, g.StatsID, g.StatsPolicy, g.StatsAtMs = []string{protocol.CloudPurposeServe, protocol.CloudPurposeStats}, make([]byte, statsIDBytes), cloudwire.StatsPolicy, 1 + h.link.mu.Lock() + err := h.link.storeLocked(g, "test") + h.link.mu.Unlock() + if err != nil { t.Fatal(err) } h.nextRoster(t) - for _, e := range svc.received() { - if e.T == cloudwire.MsgStats { - t.Fatal("old consent sent statistics") - } + time.Sleep(1500 * time.Millisecond) + if sentStats(svc) { + t.Fatal("older terms sent an hour") } - if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy}); err != nil { + + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: cloudwire.CloudTermsPolicy, By: "owner"}); err != nil { t.Fatal(err) } r := h.nextRoster(t) @@ -46,33 +78,82 @@ func TestStatisticsSendsSignedClosedHourOnlyWithCurrentConsent(t *testing.T) { if !h.link.Status().StatisticsActive { t.Fatal("current consent inactive") } - if err := h.link.StopStats("box"); err != nil { + if err := h.link.StopStats("box"); !errors.Is(err, ErrCloudNeedsStatistics) { + t.Fatalf("Cloud stopped statistics alone: %v", err) + } + if err := h.link.End("box"); err != nil { t.Fatal(err) } - r = h.nextRoster(t) - if len(r.Grant.StatsID) != 0 || h.link.Status().StatisticsActive { - t.Fatal("withdrawn consent remained active") + if h.link.Status().StatisticsActive { + t.Fatal("ended Cloud kept statistics active") } } -func TestStatisticsWaitsForTheServicesApprovedPolicy(t *testing.T) { - svc := newFakeService(t) - h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { - o.Site = statsSite{&fakeSite{}} - o.Now = func() time.Time { return time.Now().Add(-2 * time.Hour) } - }) +// A statistics-only link from a local yes follows the one answer: off sends +// no hour, and ReconcileStatistics ends the link as turning statistics off +// does. +func TestALocalNoStopsTheHourlyUpload(t *testing.T) { + svc, h := statsHarness(t, true) h.grant(t) h.nextRoster(t) - if err := h.link.Stats(StatsRequest{Policy: cloudwire.StatsPolicy}); err != nil { + g := h.record(t) + g.Policy, g.Scopes, g.Purposes = cloudwire.StatsPolicy, []string{}, []string{protocol.CloudPurposeStats} + g.StatsID, g.StatsPolicy, g.StatsAtMs = make([]byte, statsIDBytes), cloudwire.StatsPolicy, 1 + if err := h.kv.SaveConfig(statsShareKey, "off"); err != nil { + t.Fatal(err) + } + h.link.mu.Lock() + err := h.link.storeLocked(g, "test") + h.link.mu.Unlock() + if err != nil { + t.Fatal(err) + } + h.nextRoster(t) + time.Sleep(1500 * time.Millisecond) + if sentStats(svc) { + t.Fatal("an answer of off sent an hour") + } + if st := h.link.Status(); st.Statistics || st.StatisticsActive { + t.Fatalf("status says statistics run: %+v", st) + } + + // Sharing only by default is no given yes, so no hour either. + if err := h.kv.SaveConfig(statsShareKey, "default"); err != nil { + t.Fatal(err) + } + time.Sleep(1500 * time.Millisecond) + if sentStats(svc) { + t.Fatal("an answer that was never given sent an hour") + } + + // The same link with the answer on does send, so the answer is the gate. + if err := h.kv.SaveConfig(statsShareKey, "on"); err != nil { + t.Fatal(err) + } + svc.waitFor(cloudwire.MsgStats, 3*time.Second) + + if err := h.kv.SaveConfig(statsShareKey, "off"); err != nil { + t.Fatal(err) + } + if err := h.link.ReconcileStatistics("test"); err != nil { + t.Fatal(err) + } + if rec := h.record(t); !rec.Revoked || h.link.Status().Link != "ending" { + t.Fatalf("the statistics-only link did not end: %+v %+v", rec, h.link.Status()) + } +} + +func TestStatisticsWaitsForTheServicesApprovedPolicy(t *testing.T) { + svc, h := statsHarness(t, false) + if err := h.link.Grant(GrantRequest{Scopes: []string{protocol.ScopeLiveRead}, Policy: cloudwire.CloudTermsPolicy, By: "owner"}); err != nil { t.Fatal(err) } h.nextRoster(t) if h.link.Status().StatisticsReady { t.Fatal("unapproved service declared ready") } - for _, e := range svc.received() { - if e.T == cloudwire.MsgStats { - t.Fatal("statistics sent without service approval") - } + time.Sleep(time.Second) + if sentStats(svc) { + t.Fatal("statistics sent without service approval") } } diff --git a/go/internal/energyreport/day.go b/go/internal/energyreport/day.go new file mode 100644 index 000000000..29b48c5c0 --- /dev/null +++ b/go/internal/energyreport/day.go @@ -0,0 +1,117 @@ +package energyreport + +import ( + "context" + "math" + "time" + + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/protocol/energywire" +) + +// Ledger is the part of the store a day is read from. +type Ledger interface { + EnergyAssets() ([]state.EnergyAsset, error) + LoadEnergyHistoryContext(context.Context, state.EnergyHistoryQuery) ([]state.EnergyLedgerPoint, bool, error) +} + +// usable are the ledger qualities that count as a recorded bucket. A reset +// is one: a charger's session counter starts again at every plug-in, and the +// ledger covers that interval from the power reading. A gap, an invalid read +// or anything this list does not name makes the day incomplete. +var usable = map[string]bool{"measured": true, "recovered": true, "integrated": true, "estimated": true, "reset": true} + +// sums maps a ledger flow to its place in energywire.Fields. +var sums = map[state.EnergyFlow]int{ + state.FlowPVGeneration: 0, + state.FlowGridImport: 1, + state.FlowGridExport: 2, + state.FlowBatteryCharge: 3, + state.FlowBatteryDischarge: 4, + state.FlowVehicleCharge: 5, +} + +const dayMS = int64(24 * time.Hour / time.Millisecond) + +// Day reads the closed UTC day that starts at start. ok is false unless the +// whole day is there. +// +// Whole means: a site meter, and every grid meter, solar, battery and charger +// asset that was in use that day recorded every bucket of it, with nothing the +// ledger marks as a gap or invalid read. An asset that arrived or went +// silent during the day makes it incomplete; one that left before the day +// began is not part of it. A box that misses part of a day sends nothing for +// it rather than a sum that looks like a quiet day. +func Day(ctx context.Context, l Ledger, start time.Time) (energywire.Report, bool, error) { + start = start.UTC().Truncate(24 * time.Hour) + from := start.UnixMilli() + to := from + dayMS + assets, err := l.EnergyAssets() + if err != nil { + return energywire.Report{}, false, err + } + var wh [6]float64 + meter := false + for _, a := range assets { + switch a.Kind { + case state.AssetGridMeter, state.AssetPV, state.AssetBattery, state.AssetVehicleCharger: + default: + continue + } + // Gone before the day began, or first seen after it ended. + if a.LastSeenMS < from || a.FirstSeenMS >= to { + continue + } + if a.FirstSeenMS > from || a.LastSeenMS < to { + return energywire.Report{}, false, nil + } + points, truncated, err := l.LoadEnergyHistoryContext(ctx, state.EnergyHistoryQuery{ + AssetID: a.AssetID, SinceMS: from, UntilMS: to, + BucketMS: state.EnergyLedgerBucketMS, Limit: int(dayMS / state.EnergyLedgerBucketMS), + }) + if err != nil { + return energywire.Report{}, false, err + } + if truncated || !covers(points, from, to) { + return energywire.Report{}, false, nil + } + for _, p := range points { + if i, ok := sums[p.Flow]; ok { + wh[i] += p.EnergyWh + } + } + meter = meter || a.Kind == state.AssetGridMeter + } + if !meter { + return energywire.Report{}, false, nil + } + r := energywire.Report{Schema: energywire.Schema, Date: start.Format(time.DateOnly)} + fields := []*int64{&r.PVGeneration, &r.GridImport, &r.GridExport, &r.BatteryCharge, &r.BatteryDischarge, &r.VehicleCharge} + for i, v := range wh { + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return energywire.Report{}, false, nil + } + *fields[i] = int64(math.Round(v / 1000)) + } + return r, r.Valid(), nil +} + +// covers reports whether the points fill [from, to) with usable buckets of +// one width and hold nothing else. +func covers(points []state.EnergyLedgerPoint, from, to int64) bool { + if len(points) == 0 { + return false + } + width := points[0].BucketLenMS + if width <= 0 || (to-from)%width != 0 { + return false + } + seen := map[int64]bool{} + for _, p := range points { + if p.BucketLenMS != width || p.BucketStartMS < from || p.BucketStartMS >= to || (p.BucketStartMS-from)%width != 0 || !usable[p.Quality] { + return false + } + seen[p.BucketStartMS] = true + } + return int64(len(seen)) == (to-from)/width +} diff --git a/go/internal/energyreport/day_test.go b/go/internal/energyreport/day_test.go new file mode 100644 index 000000000..6cd088e80 --- /dev/null +++ b/go/internal/energyreport/day_test.go @@ -0,0 +1,216 @@ +package energyreport + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +var day = time.Date(2026, 10, 23, 0, 0, 0, 0, time.UTC) + +const tick = 2 * time.Minute + +type asset struct { + kind state.EnergyAssetKind + flow state.EnergyFlow + watts float64 + counter bool +} + +// The test site: a metered grid, solar, a battery and a charger, read every +// two minutes, the grid meter by its counters and the rest by power. +var site = map[string][]asset{ + "meter:1/grid_meter": {{state.AssetGridMeter, state.FlowGridImport, 1000, true}, {state.AssetGridMeter, state.FlowGridExport, 0, true}}, + "inverter:1/pv": {{state.AssetPV, state.FlowPVGeneration, 500, false}}, + "battery:1/battery": {{state.AssetBattery, state.FlowBatteryCharge, 250, false}, {state.AssetBattery, state.FlowBatteryDischarge, 0, false}}, + "charger:1/vehicle_charger": {{state.AssetVehicleCharger, state.FlowVehicleCharge, 1000, false}}, + "site/observed-consumer": {{state.AssetObservedConsumer, state.FlowConsumerUse, 700, false}}, +} + +func openStore(t *testing.T) *state.Store { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +// record writes the site from `from` to `to`, in time order and in one +// transaction, leaving out any asset whose id skip returns true for at that +// time. A counter keeps counting while it is not read, as a meter's does. +func record(t *testing.T, st *state.Store, from, to time.Time, skip func(id string, at time.Time) bool) { + t.Helper() + var obs []state.EnergyObservation + for at := from; !at.After(to); at = at.Add(tick) { + for id, flows := range site { + if skip != nil && skip(id, at) { + continue + } + for _, a := range flows { + w, c := a.watts, a.watts*at.Sub(from).Hours() + o := state.EnergyObservation{AssetID: id, DeviceID: id, AssetKind: a.kind, Label: "x", Flow: a.flow, AtMs: at.UnixMilli(), PowerW: &w} + if a.counter { + o.CounterWh, o.PowerW = &c, nil + } + obs = append(obs, o) + } + } + } + if err := st.RecordTickWithOptionalHistory(nil, nil, obs); err != nil { + t.Fatal(err) + } +} + +// sharedDay is the whole test day, written once and only read. +var sharedDay struct { + once sync.Once + dir string + st *state.Store + err error +} + +func TestMain(m *testing.M) { + code := m.Run() + if sharedDay.st != nil { + _ = sharedDay.st.Close() + } + if sharedDay.dir != "" { + _ = os.RemoveAll(sharedDay.dir) + } + os.Exit(code) +} + +func whole(t *testing.T) *state.Store { + t.Helper() + sharedDay.once.Do(func() { + sharedDay.dir, sharedDay.err = os.MkdirTemp("", "energyreport") + if sharedDay.err != nil { + return + } + sharedDay.st, sharedDay.err = state.Open(filepath.Join(sharedDay.dir, "state.db")) + if sharedDay.err == nil { + record(t, sharedDay.st, day.Add(-10*time.Minute), day.Add(24*time.Hour+10*time.Minute), nil) + } + }) + if sharedDay.err != nil { + t.Fatal(sharedDay.err) + } + return sharedDay.st +} + +func wholeDay(t *testing.T, skip func(string, time.Time) bool) *state.Store { + st := openStore(t) + record(t, st, day.Add(-10*time.Minute), day.Add(24*time.Hour+10*time.Minute), skip) + return st +} + +func TestAWholeDayIsSixWholeKWhSums(t *testing.T) { + st := whole(t) + r, ok, err := Day(context.Background(), st, day.Add(5*time.Hour)) + if err != nil || !ok { + t.Fatalf("ok %v err %v", ok, err) + } + if r.Date != "2026-10-23" || r.GridImport != 24 || r.GridExport != 0 || r.PVGeneration != 12 || + r.BatteryCharge != 6 || r.BatteryDischarge != 0 || r.VehicleCharge != 24 || !r.Valid() { + t.Fatalf("%+v", r) + } +} + +func TestAPartDayIsNotSent(t *testing.T) { + for name, skip := range map[string]func(string, time.Time) bool{ + "solar silent for an hour": func(id string, at time.Time) bool { + return id == "inverter:1/pv" && at.After(day.Add(13*time.Hour)) && at.Before(day.Add(14*time.Hour)) + }, + "a battery that stopped at noon": func(id string, at time.Time) bool { + return id == "battery:1/battery" && at.After(day.Add(12*time.Hour)) + }, + "a charger added at noon": func(id string, at time.Time) bool { + return id == "charger:1/vehicle_charger" && at.Before(day.Add(12*time.Hour)) + }, + "no site meter": func(id string, _ time.Time) bool { return id == "meter:1/grid_meter" }, + } { + t.Run(name, func(t *testing.T) { + if r, ok, err := Day(context.Background(), wholeDay(t, skip), day); err != nil || ok { + t.Fatalf("sent %+v (err %v)", r, err) + } + }) + } + // The day after the ledger ends has not happened yet. + if _, ok, _ := Day(context.Background(), whole(t), day.AddDate(0, 0, 1)); ok { + t.Fatal("an unrecorded day was sent") + } +} + +// A meter read by its counters keeps a whole day across an outage: the +// counter carries the three hours' energy over the gap, and the ledger +// spreads it. +func TestACounterCarriesTheMeterAcrossAnOutage(t *testing.T) { + only := map[string]bool{"meter:1/grid_meter": true} + st := wholeDay(t, func(id string, at time.Time) bool { + return !only[id] || (at.After(day.Add(3*time.Hour)) && at.Before(day.Add(6*time.Hour))) + }) + r, ok, err := Day(context.Background(), st, day) + if err != nil || !ok || r.GridImport != 24 || r.PVGeneration != 0 { + t.Fatalf("%+v ok %v err %v", r, ok, err) + } +} + +// An asset that left before the day began is not part of it. +func TestAnAssetRetiredBeforeTheDayIsLeftOut(t *testing.T) { + st := openStore(t) + record(t, st, day.Add(-48*time.Hour), day.Add(24*time.Hour+10*time.Minute), func(id string, at time.Time) bool { + return id == "battery:1/battery" && !at.Before(day.Add(-24*time.Hour)) + }) + r, ok, err := Day(context.Background(), st, day) + if err != nil || !ok || r.BatteryCharge != 0 || r.GridImport != 24 { + t.Fatalf("%+v ok %v err %v", r, ok, err) + } +} + +// An asset first seen after the day ended is not part of it. +func TestAnAssetAddedAfterTheDayLeavesItWhole(t *testing.T) { + st := wholeDay(t, nil) + w := 400.0 + for _, at := range []time.Time{day.Add(24*time.Hour + 30*time.Minute), day.Add(24*time.Hour + 32*time.Minute)} { + o := state.EnergyObservation{AssetID: "battery:2/battery", DeviceID: "battery:2", AssetKind: state.AssetBattery, Label: "x", + Flow: state.FlowBatteryCharge, AtMs: at.UnixMilli(), PowerW: &w} + if err := st.RecordTickWithOptionalHistory(nil, nil, []state.EnergyObservation{o}); err != nil { + t.Fatal(err) + } + } + if r, ok, err := Day(context.Background(), st, day); err != nil || !ok || r.BatteryCharge != 6 { + t.Fatalf("%+v ok %v err %v", r, ok, err) + } +} + +// A charger's session counter starts again at every plug-in. The ledger marks +// a reset and covers the interval from the power reading, so the day stays +// whole and the charge is counted. +func TestASessionCounterThatStartsAgainKeepsTheDay(t *testing.T) { + st := wholeDay(t, func(id string, _ time.Time) bool { return id == "charger:1/vehicle_charger" }) + var obs []state.EnergyObservation + first := day.Add(-10 * time.Minute) + for at := first; !at.After(day.Add(24*time.Hour + 10*time.Minute)); at = at.Add(tick) { + session := first + if !at.Before(day.Add(12 * time.Hour)) { + session = day.Add(12 * time.Hour) + } + w, c := 1000.0, 1000*at.Sub(session).Hours() + obs = append(obs, state.EnergyObservation{AssetID: "charger:2/vehicle_charger", DeviceID: "charger:2", AssetKind: state.AssetVehicleCharger, + Label: "x", Flow: state.FlowVehicleCharge, AtMs: at.UnixMilli(), PowerW: &w, CounterWh: &c}) + } + if err := st.RecordTickWithOptionalHistory(nil, nil, obs); err != nil { + t.Fatal(err) + } + r, ok, err := Day(context.Background(), st, day) + if err != nil || !ok || r.VehicleCharge != 24 { + t.Fatalf("%+v ok %v err %v", r, ok, err) + } +} diff --git a/go/internal/energyreport/energyreport.go b/go/internal/energyreport/energyreport.go new file mode 100644 index 000000000..75ce7451b --- /dev/null +++ b/go/internal/energyreport/energyreport.go @@ -0,0 +1,236 @@ +// Package energyreport sends one anonymous energy report a day, through an +// independent Oblivious HTTP relay, so that neither the relay nor FTW Cloud +// can tell which home sent it. +// +// The report is six whole-kWh sums of one closed UTC day and its date +// (package energywire). Package statsrelay seals it to FTW Cloud's gateway +// key, pinned in this release, and posts it to the relay: +// +// - the relay sees the box's address, the time and a ciphertext of fixed +// length, never the report; +// - the gateway sees the report and the relay's address, never the box's. +// It adds the sums to that day's totals and keeps nothing of the report. +// +// Two things keep the report from being joined to the daily fleet report, +// which carries the box's drivers, zone and size: it carries no field the +// fleet report carries, and it is sent alone at its own time, drawn afresh +// for each day from 1 to 12 hours after the day closes. With a fleet of a +// few boxes those fields can point at one home, so energy sent beside them +// would be that home's again. +// +// It runs only while the box shares statistics (cloudlink.SharesStatistics), +// the one answer that also covers the fleet report, and never for a site a +// partner controls. It adds no question or setting of its own. +package energyreport + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "time" + + "github.com/srcfl/ftw/go/internal/statsrelay" + "github.com/srcfl/ftw/go/protocol/energywire" +) + +const ( + // The send window after a day closes: late enough that the day's last + // readings are in the ledger, early enough that the gateway still takes + // the date. + firstSend = time.Hour + lastSend = 12 * time.Hour + + // lastDayKey records the last date this box sent, on the box only, so a + // restart does not send a day twice. + lastDayKey = "energy_report.last_date" +) + +// KV is where the box remembers the last date it sent. +type KV interface { + LoadConfig(key string) (string, bool) + SaveConfig(key, value string) error +} + +// Options wires the sender. +type Options struct { + // Enabled is the owner's answer and the partner rule, read at each send. + Enabled func() bool + Ledger Ledger + KV KV + + // Relay defaults to statsrelay.Pinned. Tests set a fake relay. + Relay *statsrelay.Client + Logger *slog.Logger + + // Injectable seams. Production leaves them nil. + Now func() time.Time + Random func() float64 + ClockSynced func() bool +} + +// Sender owns the schedule. +type Sender struct { + enabled func() bool + ledger Ledger + kv KV + relay *statsrelay.Client + log *slog.Logger + now func() time.Time + random func() float64 + synced func() bool +} + +func New(opts Options) (*Sender, error) { + if opts.Ledger == nil { + return nil, errors.New("energyreport: Ledger is required") + } + s := &Sender{enabled: opts.Enabled, ledger: opts.Ledger, kv: opts.KV, relay: opts.Relay, log: opts.Logger, + now: opts.Now, random: opts.Random, synced: opts.ClockSynced} + if s.relay == nil { + s.relay = statsrelay.Pinned() + } + if s.enabled == nil { + s.enabled = func() bool { return false } + } + if s.log == nil { + s.log = slog.Default() + } + s.log = s.log.With("component", "energyreport") + if s.now == nil { + s.now = time.Now + } + if s.random == nil { + s.random = randomFloat + } + if s.synced == nil { + s.synced = func() bool { return true } + } + return s, nil +} + +// Preview is what the box would send for yesterday, for Settings. +type Preview struct { + // Relay is where it goes, empty when this build has no relay. + Relay string `json:"relay"` + // Sending is whether this box sends at all: a relay and a gateway key in + // this build, and the owner's answer now. + Sending bool `json:"sending"` + // Day is the closed UTC day it describes. + Day string `json:"day"` + // Payload is the report, or nil when that day is incomplete. + Payload *energywire.Report `json:"payload"` +} + +// Preview builds yesterday's report with the same call the sender uses. +func (s *Sender) Preview(ctx context.Context) Preview { + day := s.now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -1) + p := Preview{Day: day.Format(time.DateOnly)} + if s.relay != nil { + p.Relay, p.Sending = s.relay.Relay(), s.enabled() + } + if r, ok, err := Day(ctx, s.ledger, day); err == nil && ok { + p.Payload = &r + } + return p +} + +// Run sends once a day until ctx ends. Without a relay and a gateway key it +// returns at once and sends nothing. +func (s *Sender) Run(ctx context.Context) { + if s.relay == nil { + return + } + var p plan + for { + now := s.now() + if !p.live(now) { + p = s.draw(now) + } + if !now.Before(p.at) { + s.send(ctx, p.day) + p.done = true + continue + } + // Wake at least hourly, so a wall clock that NTP moves is noticed + // and the plan drawn again. + timer := time.NewTimer(min(p.at.Sub(now), time.Hour)) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + +// plan is one day's report: the day it describes and when to send it. +type plan struct { + day time.Time + at time.Time + done bool +} + +// live reports whether the plan still belongs to the clock's day. +func (p plan) live(now time.Time) bool { + return !p.done && !p.day.IsZero() && !now.Before(p.day) && now.Before(p.day.Add(24*time.Hour+lastSend)) +} + +// draw plans the next report. Within a day's send window and before that day +// is sent, the send time is drawn from what is left of the window; otherwise +// from the next day's window. The time is drawn afresh each day, so it does +// not settle into a slot that would name the box. +func (s *Sender) draw(now time.Time) plan { + now = now.UTC() + closed := now.Truncate(24 * time.Hour) + day := closed.AddDate(0, 0, -1) + end := closed.Add(lastSend) + if now.Before(end) && !s.sent(day) { + from := closed.Add(firstSend) + if now.After(from) { + from = now + } + return plan{day: day, at: from.Add(time.Duration(s.random() * float64(end.Sub(from))))} + } + next := closed.AddDate(0, 0, 1) + return plan{day: closed, at: next.Add(firstSend + time.Duration(s.random()*float64(lastSend-firstSend)))} +} + +func (s *Sender) sent(day time.Time) bool { + if s.kv == nil { + return false + } + last, ok := s.kv.LoadConfig(lastDayKey) + return ok && last >= day.Format(time.DateOnly) +} + +// send makes one attempt for one day and forgets about it. There is no +// retry: a lost report is one fewer in a count that says it counts reports. +func (s *Sender) send(ctx context.Context, day time.Time) { + if s.relay == nil || !s.synced() || !s.enabled() || s.sent(day) { + return + } + r, ok, err := Day(ctx, s.ledger, day) + if err != nil || !ok { + s.log.Debug("no complete day to report", "day", day.Format(time.DateOnly), "err", err) + return + } + // The answer may have changed while the ledger was read. + if !s.enabled() { + return + } + // Remembered before it goes, so a crash mid-send loses the day rather + // than counting it twice. + if s.kv != nil { + if err := s.kv.SaveConfig(lastDayKey, r.Date); err != nil { + return + } + } + body, err := json.Marshal(r) + if err != nil { + return + } + if err := s.relay.Send(ctx, body); err != nil { + s.log.Debug("energy report did not go through", "err", err) + } +} diff --git a/go/internal/energyreport/energyreport_test.go b/go/internal/energyreport/energyreport_test.go new file mode 100644 index 000000000..b6b19863e --- /dev/null +++ b/go/internal/energyreport/energyreport_test.go @@ -0,0 +1,140 @@ +package energyreport + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/statsrelay/statsrelaytest" +) + +func TestTheSendTimeIsDrawnInTheWindowAfterTheDayCloses(t *testing.T) { + closed := day.AddDate(0, 0, 1) + for name, c := range map[string]struct { + now time.Time + random float64 + sent string + wantDay time.Time + from, to time.Time + }{ + "boot before the window": {closed.Add(20 * time.Minute), 0, "", day, closed.Add(time.Hour), closed.Add(time.Hour)}, + "latest in the window": {closed.Add(20 * time.Minute), 1, "", day, closed.Add(12 * time.Hour), closed.Add(12 * time.Hour)}, + "boot inside the window": {closed.Add(6 * time.Hour), 0.5, "", day, closed.Add(9 * time.Hour), closed.Add(9 * time.Hour)}, + "boot after the window": {closed.Add(13 * time.Hour), 0, "", closed, closed.Add(25 * time.Hour), closed.Add(25 * time.Hour)}, + "that day was sent already": {closed.Add(6 * time.Hour), 0, "2026-10-23", closed, closed.Add(25 * time.Hour), closed.Add(25 * time.Hour)}, + "a later day was sent first": {closed.Add(6 * time.Hour), 0, "2026-10-24", closed, closed.Add(25 * time.Hour), closed.Add(25 * time.Hour)}, + } { + kv := memKV{} + if c.sent != "" { + kv[lastDayKey] = c.sent + } + s := newSender(t, Options{KV: kv, Random: func() float64 { return c.random }}) + p := s.draw(c.now) + if !p.day.Equal(c.wantDay) || p.at.Before(c.from) || p.at.After(c.to) || !p.live(c.now) { + t.Fatalf("%s: plan %+v", name, p) + } + } + // A clock that jumps a year makes the plan stale. + p := plan{day: day, at: day.Add(30 * time.Hour)} + if p.live(day.AddDate(1, 0, 0)) || p.live(day.AddDate(-1, 0, 0)) || !p.live(day.Add(30*time.Hour)) { + t.Fatal("a stale plan stayed live") + } +} + +// Box to relay to gateway: the gateway reads the day's report, and a restart +// does not send the day again. +func TestABoxReportReachesTheGatewayThroughTheRelay(t *testing.T) { + client, gw, relay := statsrelaytest.New(t, day.Add(30*time.Hour)) + kv := memKV{} + s := newSender(t, Options{Ledger: whole(t), KV: kv, Relay: client, Enabled: func() bool { return true }, + Now: func() time.Time { return day.Add(30 * time.Hour) }}) + + s.send(context.Background(), day) + got := gw.Energy() + if len(got) != 1 || got[0].Date != "2026-10-23" || got[0].GridImport != 24 || got[0].VehicleCharge != 24 { + t.Fatalf("gateway got %+v", got) + } + if kv[lastDayKey] != "2026-10-23" || gw.Answered() != http.StatusNoContent || len(relay.Bodies()) != 1 { + t.Fatalf("kv %v answered %d", kv, gw.Answered()) + } + s.send(context.Background(), day) + if len(gw.Energy()) != 1 { + t.Fatal("the same day went twice") + } +} + +func TestNothingIsSentWithoutTheOwnersAnswer(t *testing.T) { + client, _, relay := statsrelaytest.New(t, day.Add(30*time.Hour)) + ledger := whole(t) + var mu sync.Mutex + reads := 0 + for name, o := range map[string]Options{ + "no answer": {Enabled: nil}, + "the answer is no": {Enabled: func() bool { return false }}, + "clock not set": {Enabled: func() bool { return true }, ClockSynced: func() bool { return false }}, + // Withdrawn while the ledger was being read. + "withdrawn mid-read": {Enabled: func() bool { + mu.Lock() + defer mu.Unlock() + reads++ + return reads == 1 + }}, + "incomplete day": {Enabled: func() bool { return true }, Ledger: wholeDay(t, func(id string, at time.Time) bool { return id == "inverter:1/pv" && at.After(day.Add(time.Hour)) })}, + } { + if o.Ledger == nil { + o.Ledger = ledger + } + o.Relay, o.KV = client, memKV{} + newSender(t, o).send(context.Background(), day) + if len(relay.Bodies()) != 0 { + t.Fatalf("%s: sent", name) + } + } +} + +// Without a relay and a gateway key, Run returns and never reads the ledger. +func TestWithoutARelayRunSendsNothing(t *testing.T) { + l := &countingLedger{} + s := newSender(t, Options{Ledger: l, Enabled: func() bool { return true }}) + done := make(chan struct{}) + go func() { s.Run(context.Background()); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run waited with nowhere to send") + } + if l.n != 0 { + t.Fatal("read the ledger with nowhere to send") + } + if p := s.Preview(context.Background()); p.Relay != "" || p.Day == "" { + t.Fatalf("preview %+v", p) + } +} + +func newSender(t *testing.T, o Options) *Sender { + t.Helper() + if o.Ledger == nil { + o.Ledger = &countingLedger{} + } + s, err := New(o) + if err != nil { + t.Fatal(err) + } + return s +} + +type memKV map[string]string + +func (m memKV) LoadConfig(k string) (string, bool) { v, ok := m[k]; return v, ok } +func (m memKV) SaveConfig(k, v string) error { m[k] = v; return nil } + +type countingLedger struct{ n int } + +func (c *countingLedger) EnergyAssets() ([]state.EnergyAsset, error) { c.n++; return nil, nil } +func (c *countingLedger) LoadEnergyHistoryContext(context.Context, state.EnergyHistoryQuery) ([]state.EnergyLedgerPoint, bool, error) { + c.n++ + return nil, false, nil +} diff --git a/go/internal/energyreport/random.go b/go/internal/energyreport/random.go new file mode 100644 index 000000000..ec5eef048 --- /dev/null +++ b/go/internal/energyreport/random.go @@ -0,0 +1,16 @@ +package energyreport + +import ( + "crypto/rand" + "math/big" +) + +// randomFloat draws from the system source, as fleetping's does. +func randomFloat() float64 { + const precision = 1 << 53 + n, err := rand.Int(rand.Reader, big.NewInt(precision)) + if err != nil { + return 0.5 + } + return float64(n.Int64()) / precision +} diff --git a/go/internal/fleetping/fleetping.go b/go/internal/fleetping/fleetping.go index 812349f48..c5e21292f 100644 --- a/go/internal/fleetping/fleetping.go +++ b/go/internal/fleetping/fleetping.go @@ -20,10 +20,15 @@ // - a send time that moves. A box that pings at 03:17 every night has told // the endpoint its name in a field nobody thought to look at. // -// What this cannot fix is the source IP, which the endpoint sees and which a -// household often keeps for months. That is the honest limit: this design -// makes the *payload* useless as an identifier, it does not make the -// connection anonymous. The drifting send time is what stops arrival time +// Sent straight to an endpoint, the source IP is what this cannot fix: the +// endpoint sees it, and a household often keeps one for months. A release +// that names a relay (package statsrelay) sends the report to FTW Cloud +// through it instead, sealed with Oblivious HTTP, so FTW Cloud reads the +// report without the address and the relay sees the address without the +// report. The sealed request also carries a random number drawn new for each +// report, and the day it was sent (package reportwire); neither joins two +// pings. Without a relay, this design makes the *payload* useless as an +// identifier; it does not make the connection anonymous. The drifting send time is what stops arrival time // becoming a second selector alongside it — though the drift is a random walk // in six-hour steps, so it takes about four days to spread across the clock, // and until then one box's arrival times are still loosely related. @@ -53,6 +58,7 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/prices" + "github.com/srcfl/ftw/go/internal/statsrelay" "github.com/srcfl/ftw/go/protocol/fleetwire" ) @@ -347,6 +353,26 @@ func (h *HTTPProvider) Send(ctx context.Context, p Payload) error { return nil } +// RelayProvider sends the report through the relay (package statsrelay), as +// its own message at its own time: never beside the energy report, whose +// sums a driver set and a price zone could otherwise tie to one home. +type RelayProvider struct { + c *statsrelay.Client +} + +func NewRelayProvider(c *statsrelay.Client) *RelayProvider { return &RelayProvider{c: c} } + +func (p *RelayProvider) Send(ctx context.Context, payload Payload) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + return p.c.Send(ctx, body) +} + +// EndpointURL is the relay, which is where the box sends. +func (p *RelayProvider) EndpointURL() string { return p.c.Relay() } + // Options wires the pinger. Everything except the injectable seams is required. type Options struct { // Enabled is read at each send rather than captured, so turning the ping @@ -426,6 +452,16 @@ func (p *Pinger) Endpoint() string { return "" } +// Enabled is whether the next report would go, read the way the sender reads +// it, so Settings says what the box does. +func (p *Pinger) Enabled() bool { return p.enabled() } + +// ThroughRelay reports whether the running provider sends through the relay. +func (p *Pinger) ThroughRelay() bool { + _, ok := p.provider.(*RelayProvider) + return ok +} + // Run pings once a day until ctx ends. func (p *Pinger) Run(ctx context.Context) { for { diff --git a/go/internal/fleetping/fleetping_test.go b/go/internal/fleetping/fleetping_test.go index b0848ab9b..b5ecb5e2c 100644 --- a/go/internal/fleetping/fleetping_test.go +++ b/go/internal/fleetping/fleetping_test.go @@ -1,6 +1,7 @@ package fleetping import ( + "bytes" "context" "encoding/json" @@ -15,6 +16,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/statsrelay/statsrelaytest" ) // The whole point of this package is what it does NOT send. These tests are @@ -542,3 +544,26 @@ func TestHTTPProviderDefaultClientHasATimeout(t *testing.T) { t.Fatal("the default client has no timeout") } } + +// Through the relay the gateway reads the same report the box would post +// directly, and the relay sees none of it. +func TestTheReportGoesThroughTheRelayUnchanged(t *testing.T) { + client, gw, relay := statsrelaytest.New(t, time.Now()) + p := NewRelayProvider(client) + facts := Facts{Version: "v1.4.0", Drivers: []string{"sungrow"}, Catalogue: []string{"sungrow"}, PriceZone: "SE3"} + pinger, err := New(Options{Facts: func() Facts { return facts }, Provider: p}) + if err != nil { + t.Fatal(err) + } + want := pinger.Payload() + if err := p.Send(context.Background(), want); err != nil { + t.Fatal(err) + } + got := gw.Fleet() + if len(got) != 1 || got[0].Version != want.Version || got[0].Zone != "SE3" || len(got[0].Drivers) != 1 { + t.Fatalf("gateway got %+v", got) + } + if bytes.Contains(relay.Bodies()[0], []byte("sungrow")) || !pinger.ThroughRelay() || pinger.Endpoint() != relay.URL { + t.Fatal("the relay saw the report, or the pinger does not say where it sends") + } +} diff --git a/go/internal/statsrelay/statsrelay.go b/go/internal/statsrelay/statsrelay.go new file mode 100644 index 000000000..44c81f515 --- /dev/null +++ b/go/internal/statsrelay/statsrelay.go @@ -0,0 +1,162 @@ +// Package statsrelay is how the box's anonymous daily reports leave it once a +// release names a relay: each report alone, sealed with Oblivious HTTP +// (RFC 9458) to FTW Cloud's gateway key and posted to a relay that someone +// other than Sourceful runs. The relay sees the box's address but not the +// report; FTW Cloud reads the report but not the address. +// +// Two reports use it, the fleet report (package fleetping) and the energy +// report (package energyreport), each at its own random time and never in +// one message. +package statsrelay + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strings" + "time" + + "github.com/srcfl/ftw/go/protocol/ohttp" + "github.com/srcfl/ftw/go/protocol/reportwire" +) + +// Compile-time settings, as in cloudlink: a relay a household could point +// elsewhere is a relay a support call could point at an attacker's. A local +// test build may set them with -ldflags -X. +var ( + // RelayURL is the OHTTP relay, run by someone other than Sourceful. + // Empty until that relay exists: the energy report is then not sent, + // and the fleet report goes straight to FTW Cloud as before. + RelayURL = "" + // GatewayKey is FTW Cloud's gateway key configuration (RFC 9458, + // section 3), base64url, as + // https://cloud.ftw.energy/.well-known/ohttp-gateway serves it. The box + // encrypts only to this key and never asks the service for one, so every + // box on a release uses the same key and a key swapped in at the service + // reads nothing. Empty means no gateway key exists for this build. + GatewayKey = "" +) + +const ( + sendTimeout = 30 * time.Second + maxResponseBytes = 1 << 10 +) + +// Client posts sealed reports to one relay under one gateway key. +type Client struct { + relay string + key ohttp.KeyConfig + http *http.Client + now func() time.Time +} + +// Pinned is this build's client, or nil when the build names no usable relay +// and gateway key. +func Pinned() *Client { + relay, key, ok := pinned(RelayURL, GatewayKey) + if !ok { + return nil + } + return New(relay, key, nil) +} + +func pinned(relay, key string) (string, ohttp.KeyConfig, bool) { + raw, err := base64.RawURLEncoding.DecodeString(key) + if err != nil || relay == "" || key == "" { + return "", ohttp.KeyConfig{}, false + } + cfg, err := ohttp.ParseKeyConfig(raw) + if err != nil { + return "", ohttp.KeyConfig{}, false + } + u, err := url.Parse(relay) + if err != nil { + return "", ohttp.KeyConfig{}, false + } + // Host names compare without case and without a trailing dot, so FTW + // Cloud cannot pass as its own relay under another spelling. + host := strings.TrimSuffix(u.Hostname(), ".") + if u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.EqualFold(host, reportwire.Authority) { + return "", ohttp.KeyConfig{}, false + } + return relay, cfg, true +} + +// New is a client for one relay and key. Tests point it at a fake relay. +func New(relay string, key ohttp.KeyConfig, client *http.Client) *Client { + if client == nil { + client = &http.Client{Timeout: sendTimeout} + } else { + c := *client + client = &c + } + // A redirect would resend the sealed report to an address nobody chose. + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &Client{relay: relay, key: key, http: client, now: time.Now} +} + +// Relay is the relay's URL, for Settings. +func (c *Client) Relay() string { return c.relay } + +// Send seals one report body and reads the gateway's answer. Only a sealed +// 204 from the gateway the box encrypted to counts as delivered. +// +// Each call is one report: it draws a fresh id, which the gateway counts +// once. Nothing retries; a retry of the same report would have to reuse its +// id, or the gateway would count it twice. +func (c *Client) Send(ctx context.Context, body []byte) error { + id := make([]byte, reportwire.IDBytes) + if _, err := rand.Read(id); err != nil { + return err + } + inner, ok := reportwire.Request(reportwire.Report{ID: id, Day: c.now().UTC().Format(time.DateOnly), Body: body}) + if !ok { + return errors.New("statsrelay: the report does not fit the padded request") + } + sealed, reader, err := ohttp.Encapsulate(c.key, inner) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.relay, bytes.NewReader(sealed)) + if err != nil { + return err + } + req.Header.Set("Content-Type", ohttp.RequestType) + // Pinned, as for the fleet report's direct path: the relay sees it, and + // Go's default names the runtime's version. + req.Header.Set("User-Agent", "ftw-report") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return err + } + media, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if resp.StatusCode != http.StatusOK || media != ohttp.ResponseType || len(data) > maxResponseBytes { + return fmt.Errorf("statsrelay: relay answered %d %q", resp.StatusCode, media) + } + msg, err := reader.Open(data) + if err != nil { + return err + } + answer, err := ohttp.ParseResponse(msg) + if err != nil { + return err + } + if answer.Status != http.StatusNoContent { + return fmt.Errorf("statsrelay: gateway answered %d", answer.Status) + } + return nil +} diff --git a/go/internal/statsrelay/statsrelay_test.go b/go/internal/statsrelay/statsrelay_test.go new file mode 100644 index 000000000..00d4559e1 --- /dev/null +++ b/go/internal/statsrelay/statsrelay_test.go @@ -0,0 +1,133 @@ +package statsrelay_test + +import ( + "bytes" + "context" + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/statsrelay" + "github.com/srcfl/ftw/go/internal/statsrelay/statsrelaytest" + "github.com/srcfl/ftw/go/protocol/energywire" + "github.com/srcfl/ftw/go/protocol/ohttp" + "github.com/srcfl/ftw/go/protocol/reportwire" +) + +var now = time.Date(2026, 10, 24, 9, 0, 0, 0, time.UTC) + +// The release names no relay and no gateway key yet, so no report goes +// through one. Once both are pinned they must be a usable pair. +func TestTheReleasePinsBothOrNeither(t *testing.T) { + c := statsrelay.Pinned() + switch { + case statsrelay.RelayURL == "" && statsrelay.GatewayKey == "": + if c != nil { + t.Fatal("nothing pinned, yet a client") + } + case c == nil: + t.Fatalf("the pinned relay %q and gateway key do not make a usable pair", statsrelay.RelayURL) + } +} + +func TestAPinIsRefusedUnlessItIsARelayAndAKey(t *testing.T) { + k, _ := ecdh.X25519().GenerateKey(nil) + gw, _ := ohttp.NewGateway(ohttp.Key{ID: 1, Private: k}) + key := base64.RawURLEncoding.EncodeToString(gw.Configs()[0].Marshal()) + oldRelay, oldKey := statsrelay.RelayURL, statsrelay.GatewayKey + t.Cleanup(func() { statsrelay.RelayURL, statsrelay.GatewayKey = oldRelay, oldKey }) + set := func(relay, key string) *statsrelay.Client { + statsrelay.RelayURL, statsrelay.GatewayKey = relay, key + return statsrelay.Pinned() + } + if c := set("https://relay.example.net/ftw", key); c == nil || c.Relay() != "https://relay.example.net/ftw" { + t.Fatal("a good pair was refused") + } + for name, p := range map[string][2]string{ + "no relay": {"", key}, + "no key": {"https://relay.example.net/ftw", ""}, + "plain http": {"http://relay.example.net/ftw", key}, + "FTW Cloud itself": {"https://cloud.ftw.energy/v1/ohttp", key}, + "FTW Cloud, cased": {"https://Cloud.FTW.Energy/v1/ohttp", key}, + "FTW Cloud, dot": {"https://cloud.ftw.energy./v1/ohttp", key}, + "query": {"https://relay.example.net/ftw?box=1", key}, + "credentials": {"https://a:b@relay.example.net/ftw", key}, + "bad key": {"https://relay.example.net/ftw", "AAAA"}, + "padded base64": {"https://relay.example.net/ftw", key + "="}, + } { + if set(p[0], p[1]) != nil { + t.Fatalf("%s was accepted", name) + } + } +} + +// The relay sees one length and no report; the gateway reads it. +func TestAReportReachesTheGatewayThroughTheRelay(t *testing.T) { + client, gw, relay := statsrelaytest.New(t, now) + body, _ := json.Marshal(energywire.Report{Schema: energywire.Schema, Date: "2026-10-23", GridImport: 24}) + if err := client.Send(context.Background(), body); err != nil { + t.Fatal(err) + } + if got := gw.Energy(); len(got) != 1 || got[0].GridImport != 24 { + t.Fatalf("gateway got %+v", got) + } + seen := relay.Bodies()[0] + if bytes.Contains(seen, []byte("kwh")) || len(seen) != 7+32+reportwire.RequestSize+16 { + t.Fatalf("the relay saw %d bytes", len(seen)) + } + h := relay.Headers()[0] + if h.Get("Content-Type") != ohttp.RequestType || h.Get("User-Agent") != "ftw-report" { + t.Fatalf("relay headers %v", h) + } + if err := client.Send(context.Background(), make([]byte, reportwire.RequestSize)); err == nil { + t.Fatal("a report too large for the padding went out") + } +} + +// The box believes only a sealed 204 from the gateway it encrypted to. +func TestOnlyTheGatewaysSealed204Counts(t *testing.T) { + k, _ := ecdh.X25519().GenerateKey(nil) + gw, _ := ohttp.NewGateway(ohttp.Key{ID: 1, Private: k}) + sealed := func(status int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _, ctx, err := gw.Decapsulate(body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + answer, _ := ctx.Seal(reportwire.Response(status)) + w.Header().Set("Content-Type", ohttp.ResponseType) + _, _ = w.Write(answer) + } + } + body := []byte(`{"schema":"x"}`) + for name, h := range map[string]http.HandlerFunc{ + "relay error": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }, + "plain 204": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }, + "forged body": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", ohttp.ResponseType) + _, _ = w.Write(make([]byte, 64)) + }, + "sealed 400": sealed(http.StatusBadRequest), + "redirect": func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://elsewhere.example/", http.StatusTemporaryRedirect) + }, + } { + srv := httptest.NewServer(h) + if err := statsrelay.New(srv.URL, gw.Configs()[0], nil).Send(context.Background(), body); err == nil { + t.Fatalf("%s: accepted", name) + } + srv.Close() + } + srv := httptest.NewServer(sealed(http.StatusNoContent)) + defer srv.Close() + if err := statsrelay.New(srv.URL, gw.Configs()[0], nil).Send(context.Background(), body); err != nil { + t.Fatal(err) + } +} diff --git a/go/internal/statsrelay/statsrelaytest/statsrelaytest.go b/go/internal/statsrelay/statsrelaytest/statsrelaytest.go new file mode 100644 index 000000000..1a30e5bfa --- /dev/null +++ b/go/internal/statsrelay/statsrelaytest/statsrelaytest.go @@ -0,0 +1,146 @@ +// Package statsrelaytest is a fake relay and gateway for tests. The gateway +// reads a report as FTW Cloud's does: one padded inner request, its decoder +// chosen by schema. It keeps the reports for the test to read. +package statsrelaytest + +import ( + "bytes" + "crypto/ecdh" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/statsrelay" + "github.com/srcfl/ftw/go/protocol/energywire" + "github.com/srcfl/ftw/go/protocol/fleetwire" + "github.com/srcfl/ftw/go/protocol/ohttp" + "github.com/srcfl/ftw/go/protocol/reportwire" +) + +// Gateway is a fake FTW Cloud gateway. +type Gateway struct { + *httptest.Server + Key ohttp.KeyConfig + + mu sync.Mutex + now time.Time + fleet []fleetwire.Report + energy []energywire.Report + last int +} + +// Relay forwards only the body and its content type, and records what it saw. +type Relay struct { + *httptest.Server + + mu sync.Mutex + bodies [][]byte + headers []http.Header +} + +// New starts a gateway whose clock reads now, a relay in front of it, and a +// client posting to the relay. +func New(t testing.TB, now time.Time) (*statsrelay.Client, *Gateway, *Relay) { + t.Helper() + k, err := ecdh.X25519().GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + gw, err := ohttp.NewGateway(ohttp.Key{ID: 7, Private: k}) + if err != nil { + t.Fatal(err) + } + g := &Gateway{Key: gw.Configs()[0], now: now} + g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + msg, sealer, err := gw.Decapsulate(body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + status := g.add(msg) + sealed, _ := sealer.Seal(reportwire.Response(status)) + w.Header().Set("Content-Type", ohttp.ResponseType) + _, _ = w.Write(sealed) + })) + t.Cleanup(g.Close) + + rl := &Relay{} + rl.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + rl.mu.Lock() + rl.bodies = append(rl.bodies, body) + rl.headers = append(rl.headers, r.Header.Clone()) + rl.mu.Unlock() + resp, err := http.Post(g.URL, r.Header.Get("Content-Type"), bytes.NewReader(body)) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + return + } + defer resp.Body.Close() + w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + })) + t.Cleanup(rl.Close) + return statsrelay.New(rl.URL, g.Key, nil), g, rl +} + +func (g *Gateway) add(msg []byte) int { + g.mu.Lock() + defer g.mu.Unlock() + g.last = http.StatusBadRequest + report, ok := reportwire.Read(msg) + if !ok || !reportwire.Fresh(report.Day, g.now) { + return g.last + } + body := report.Body + switch reportwire.Schema(body) { + case fleetwire.Schema: + if r, err := fleetwire.Decode(body); err == nil { + g.fleet, g.last = append(g.fleet, r), http.StatusNoContent + } + case energywire.Schema: + if r, err := energywire.Decode(body, g.now); err == nil { + g.energy, g.last = append(g.energy, r), http.StatusNoContent + } + } + return g.last +} + +// Fleet is every fleet report the gateway took. +func (g *Gateway) Fleet() []fleetwire.Report { + g.mu.Lock() + defer g.mu.Unlock() + return append([]fleetwire.Report(nil), g.fleet...) +} + +// Energy is every energy report the gateway took. +func (g *Gateway) Energy() []energywire.Report { + g.mu.Lock() + defer g.mu.Unlock() + return append([]energywire.Report(nil), g.energy...) +} + +// Answered is the gateway's last inner status. +func (g *Gateway) Answered() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.last +} + +// Bodies is every body the relay forwarded, and Headers what came with each. +func (r *Relay) Bodies() [][]byte { + r.mu.Lock() + defer r.mu.Unlock() + return append([][]byte(nil), r.bodies...) +} + +func (r *Relay) Headers() []http.Header { + r.mu.Lock() + defer r.mu.Unlock() + return append([]http.Header(nil), r.headers...) +} diff --git a/go/protocol/energywire/energy.go b/go/protocol/energywire/energy.go new file mode 100644 index 000000000..08c95fd89 --- /dev/null +++ b/go/protocol/energywire/energy.go @@ -0,0 +1,105 @@ +// Package energywire names the anonymous daily energy report: one closed UTC +// day of one home as six whole-kWh sums, and nothing that says whose. +// +// It travels alone through a relay (package reportwire), so the gateway never +// sees the box's address. It carries no id, version, driver, zone or size, +// because the daily fleet report (package fleetwire) carries those, and the +// two must never be joined into one home's record. +package energywire + +import ( + "bytes" + "encoding/json" + "errors" + "time" +) + +const Schema = "ftw.energy/1" + +const ( + // MaxBodyBytes bounds the report itself, before any parsing. + MaxBodyBytes = 256 + + // MaxKWh bounds one field of one day. A home on a 3 × 63 A fuse moves + // at most about 1,000 kWh a day. + MaxKWh = 1000 + + // AcceptDays is how many closed days back a report may name. A box sends + // 1 to 12 hours after the day closes, so one day would do; three leave + // room for a slow clock. + AcceptDays = 3 + + // MaxReports caps one date's reports, so junk can add bounded noise and + // no more. + MaxReports = 100_000 +) + +// Fields are the report's sums, in the order the public view lists them. +var Fields = []string{"pv_generation_kwh", "grid_import_kwh", "grid_export_kwh", "battery_charge_kwh", "battery_discharge_kwh", "vehicle_charge_kwh"} + +// Report is one home's closed UTC day. Never add an identifier. +type Report struct { + Schema string `json:"schema"` + Date string `json:"date"` + PVGeneration int64 `json:"pv_generation_kwh"` + GridImport int64 `json:"grid_import_kwh"` + GridExport int64 `json:"grid_export_kwh"` + BatteryCharge int64 `json:"battery_charge_kwh"` + BatteryDischarge int64 `json:"battery_discharge_kwh"` + VehicleCharge int64 `json:"vehicle_charge_kwh"` +} + +// Sums are the report's fields in Fields order. +func (r Report) Sums() []int64 { + return []int64{r.PVGeneration, r.GridImport, r.GridExport, r.BatteryCharge, r.BatteryDischarge, r.VehicleCharge} +} + +// Valid checks the fields a sender fills. The date is checked against the +// receiver's clock in Decode. +func (r Report) Valid() bool { + if r.Schema != Schema || !isDate(r.Date) { + return false + } + for _, v := range r.Sums() { + if v < 0 || v > MaxKWh { + return false + } + } + return true +} + +var errBad = errors.New("energy: invalid report") + +// Decode accepts only the bytes a box writes: the eight fields in their +// order, whole numbers in range, and a date in the last AcceptDays closed UTC +// days. Another order, spacing, a repeated or extra field, or a number +// written another way is refused. +func Decode(data []byte, now time.Time) (Report, error) { + var r Report + if len(data) > MaxBodyBytes || json.Unmarshal(data, &r) != nil || !r.Valid() || !Accepts(r.Date, now) { + return Report{}, errBad + } + if canonical, err := json.Marshal(r); err != nil || !bytes.Equal(canonical, data) { + return Report{}, errBad + } + return r, nil +} + +// Accepts reports whether date is one of the last AcceptDays closed UTC days. +func Accepts(date string, now time.Time) bool { + day, err := time.Parse(time.DateOnly, date) + today := now.UTC().Truncate(24 * time.Hour) + return err == nil && day.Before(today) && !day.Before(today.AddDate(0, 0, -AcceptDays)) +} + +// Closed reports whether no report may name date any more, so its totals are +// final. +func Closed(date string, now time.Time) bool { + day, err := time.Parse(time.DateOnly, date) + return err == nil && day.Before(now.UTC().Truncate(24*time.Hour).AddDate(0, 0, -AcceptDays)) +} + +func isDate(s string) bool { + t, err := time.Parse(time.DateOnly, s) + return err == nil && t.Format(time.DateOnly) == s +} diff --git a/go/protocol/energywire/energy_test.go b/go/protocol/energywire/energy_test.go new file mode 100644 index 000000000..0b6222ab4 --- /dev/null +++ b/go/protocol/energywire/energy_test.go @@ -0,0 +1,71 @@ +package energywire + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +var now = time.Date(2026, 10, 24, 9, 30, 0, 0, time.UTC) + +const valid = `{"schema":"ftw.energy/1","date":"2026-10-23","pv_generation_kwh":12,"grid_import_kwh":30,"grid_export_kwh":4,"battery_charge_kwh":9,"battery_discharge_kwh":8,"vehicle_charge_kwh":0}` + +func TestOnlySixSumsAndADateAreAccepted(t *testing.T) { + r, err := Decode([]byte(valid), now) + if err != nil || r.PVGeneration != 12 || r.GridImport != 30 || r.Date != "2026-10-23" { + t.Fatalf("%+v %v", r, err) + } + for _, bad := range []string{ + strings.Replace(valid, `"ftw.energy/1"`, `"ftw.energy/2"`, 1), + strings.Replace(valid, `2026-10-23`, `2026-10-24`, 1), // today is still open + strings.Replace(valid, `2026-10-23`, `2026-10-20`, 1), // four days back + strings.Replace(valid, `2026-10-23`, `2026-10-32`, 1), + strings.Replace(valid, `2026-10-23`, `2026-1-23`, 1), + strings.Replace(valid, `:12,`, `:12.5,`, 1), + strings.Replace(valid, `:12,`, `:1e1,`, 1), + strings.Replace(valid, `:12,`, `:-1,`, 1), + strings.Replace(valid, `:12,`, `:1001,`, 1), + strings.Replace(valid, `:12,`, `:"12",`, 1), + strings.Replace(valid, `,"vehicle_charge_kwh":0`, ``, 1), + strings.TrimSuffix(valid, "}") + `,"home":"a"}`, + strings.TrimSuffix(valid, "}") + `,"ftw_version":"v1.0.0"}`, + strings.Replace(valid, `"vehicle_charge_kwh":0`, `"vehicle_charge_kwh":0,"vehicle_charge_kwh":0`, 1), + valid + valid, valid + ` {}`, `[]`, `null`, strings.Repeat(" ", MaxBodyBytes) + valid, + } { + if _, err := Decode([]byte(bad), now); err == nil { + t.Fatalf("accepted %s", bad) + } + } +} + +func TestTheReportHasNoFieldThatNamesAHome(t *testing.T) { + var fields map[string]any + b, _ := json.Marshal(Report{}) + if err := json.Unmarshal(b, &fields); err != nil { + t.Fatal(err) + } + if len(fields) != 8 || len(Fields) != 6 { + t.Fatalf("fields %v", fields) + } + for _, name := range append([]string{"schema", "date"}, Fields...) { + if _, ok := fields[name]; !ok { + t.Fatalf("missing %s", name) + } + } +} + +func TestOpenAndClosedDays(t *testing.T) { + for date, want := range map[string][2]bool{ + "2026-10-24": {false, false}, + "2026-10-23": {true, false}, + "2026-10-21": {true, false}, + "2026-10-20": {false, true}, + "2026-09-01": {false, true}, + "2026-10-25": {false, false}, + } { + if got := [2]bool{Accepts(date, now), Closed(date, now)}; got != want { + t.Fatalf("%s: accepts, closed = %v, want %v", date, got, want) + } + } +} diff --git a/go/protocol/ohttp/bhttp.go b/go/protocol/ohttp/bhttp.go new file mode 100644 index 000000000..cf6604b41 --- /dev/null +++ b/go/protocol/ohttp/bhttp.go @@ -0,0 +1,200 @@ +package ohttp + +import ( + "encoding/binary" + "errors" +) + +// Known-length Binary HTTP (RFC 9292), the message inside a request and its +// response. Indeterminate-length messages are not read: nothing here sends +// them. + +// ErrMessage is a malformed Binary HTTP message. +var ErrMessage = errors.New("ohttp: invalid binary HTTP message") + +// Field is one header or trailer line. +type Field struct { + Name, Value string +} + +// Request is a known-length request. Trailers are always empty. +type Request struct { + Method, Scheme, Authority, Path string + Fields []Field + Content []byte +} + +// Response is a known-length final response. Trailers are always empty, and +// informational responses are not carried. +type Response struct { + Status int + Fields []Field + Content []byte +} + +const ( + knownRequest = 0 + knownResponse = 1 +) + +// Marshal writes the request, then zero padding up to size bytes. Padding +// keeps every request the same length on the wire (RFC 9292, section 3.8). +func (r Request) Marshal(size int) []byte { + b := appendVarint(nil, knownRequest) + for _, s := range []string{r.Method, r.Scheme, r.Authority, r.Path} { + b = appendBytes(b, []byte(s)) + } + b = appendFields(b, r.Fields) + b = appendBytes(b, r.Content) + b = appendVarint(b, 0) + return pad(b, size) +} + +// Marshal writes the response, then zero padding up to size bytes. +func (r Response) Marshal(size int) []byte { + b := appendVarint(nil, knownResponse) + b = appendVarint(b, uint64(r.Status)) + b = appendFields(b, r.Fields) + b = appendBytes(b, r.Content) + b = appendVarint(b, 0) + return pad(b, size) +} + +// ParseRequest reads a known-length request. Sections after the control data +// may be truncated, and anything after the trailers must be zero padding. +func ParseRequest(b []byte) (Request, error) { + d := decoder{b: b} + if d.varint() != knownRequest { + return Request{}, ErrMessage + } + var r Request + r.Method, r.Scheme, r.Authority, r.Path = string(d.bytes()), string(d.bytes()), string(d.bytes()), string(d.bytes()) + if d.err != nil { + return Request{}, ErrMessage + } + r.Fields = d.fields() + r.Content = d.optional() + trailers := d.fields() + if d.err != nil || len(trailers) != 0 || !d.padding() { + return Request{}, ErrMessage + } + return r, nil +} + +// ParseResponse reads a known-length final response. +func ParseResponse(b []byte) (Response, error) { + d := decoder{b: b} + if d.varint() != knownResponse { + return Response{}, ErrMessage + } + status := d.varint() + if d.err != nil || status < 200 || status > 599 { + return Response{}, ErrMessage + } + r := Response{Status: int(status)} + r.Fields = d.fields() + r.Content = d.optional() + trailers := d.fields() + if d.err != nil || len(trailers) != 0 || !d.padding() { + return Response{}, ErrMessage + } + return r, nil +} + +func appendVarint(b []byte, v uint64) []byte { + switch { + case v < 1<<6: + return append(b, byte(v)) + case v < 1<<14: + return binary.BigEndian.AppendUint16(b, uint16(v)|0x4000) + case v < 1<<30: + return binary.BigEndian.AppendUint32(b, uint32(v)|0x8000_0000) + default: + return binary.BigEndian.AppendUint64(b, v|0xc000_0000_0000_0000) + } +} + +func appendBytes(b, v []byte) []byte { + return append(appendVarint(b, uint64(len(v))), v...) +} + +func appendFields(b []byte, fields []Field) []byte { + var lines []byte + for _, f := range fields { + lines = appendBytes(lines, []byte(f.Name)) + lines = appendBytes(lines, []byte(f.Value)) + } + return appendBytes(b, lines) +} + +func pad(b []byte, size int) []byte { + if len(b) < size { + b = append(b, make([]byte, size-len(b))...) + } + return b +} + +type decoder struct { + b []byte + err error +} + +func (d *decoder) varint() uint64 { + if d.err != nil || len(d.b) == 0 { + d.err = ErrMessage + return 0 + } + n := 1 << (d.b[0] >> 6) + if len(d.b) < n { + d.err = ErrMessage + return 0 + } + v := uint64(d.b[0] & 0x3f) + for _, c := range d.b[1:n] { + v = v<<8 | uint64(c) + } + d.b = d.b[n:] + return v +} + +func (d *decoder) bytes() []byte { + n := d.varint() + if d.err != nil || n > uint64(len(d.b)) { + d.err = ErrMessage + return nil + } + v := d.b[:n] + d.b = d.b[n:] + return v +} + +// optional reads a length-prefixed part that a truncated message leaves out. +func (d *decoder) optional() []byte { + if d.err != nil || len(d.b) == 0 { + return nil + } + return d.bytes() +} + +func (d *decoder) fields() []Field { + section := decoder{b: d.optional()} + var out []Field + for d.err == nil && len(section.b) > 0 { + name, value := section.bytes(), section.bytes() + if section.err != nil || len(name) == 0 { + d.err = ErrMessage + return nil + } + out = append(out, Field{Name: string(name), Value: string(value)}) + } + return out +} + +func (d *decoder) padding() bool { + for _, c := range d.b { + if c != 0 { + return false + } + } + return true +} diff --git a/go/protocol/ohttp/bhttp_test.go b/go/protocol/ohttp/bhttp_test.go new file mode 100644 index 000000000..f3bd9acc9 --- /dev/null +++ b/go/protocol/ohttp/bhttp_test.go @@ -0,0 +1,63 @@ +package ohttp + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func TestBinaryHTTPRoundTripsWithPadding(t *testing.T) { + req := Request{Method: "POST", Scheme: "https", Authority: "cloud.ftw.energy", Path: "/v1/x", + Fields: []Field{{"content-type", "application/json"}}, Content: bytes.Repeat([]byte{'a'}, 100)} + b := req.Marshal(256) + if len(b) != 256 { + t.Fatalf("padded to %d", len(b)) + } + got, err := ParseRequest(b) + if err != nil || got.Method != req.Method || got.Path != req.Path || len(got.Fields) != 1 || got.Fields[0] != req.Fields[0] || !bytes.Equal(got.Content, req.Content) { + t.Fatalf("%+v %v", got, err) + } + // Content longer than one byte's varint, and no padding asked for. + long := Request{Method: "POST", Scheme: "https", Authority: "a", Path: "/", Content: make([]byte, 20000)} + if got, err := ParseRequest(long.Marshal(0)); err != nil || len(got.Content) != 20000 { + t.Fatalf("long content %d %v", len(got.Content), err) + } + + resp := Response{Status: 204}.Marshal(16) + if r, err := ParseResponse(resp); err != nil || r.Status != 204 || len(resp) != 16 { + t.Fatalf("response %+v %v", r, err) + } +} + +func TestBinaryHTTPRefusesMalformedMessages(t *testing.T) { + good := Request{Method: "POST", Scheme: "https", Authority: "a", Path: "/", Content: []byte("x")}.Marshal(0) + withTrailer := append(bytes.Clone(good[:len(good)-1]), 4, 1, 'a', 1, 'b') + for name, b := range map[string][]byte{ + "empty": nil, + "response": Response{Status: 200}.Marshal(0), + "indeterminate": {2}, + "cut control": good[:4], + "cut content": good[:len(good)-2], + "bad padding": append(bytes.Clone(good), 0, 1), + "trailers": withTrailer, + "long length": {0, 0x3f}, + } { + if _, err := ParseRequest(b); err == nil { + t.Fatalf("%s parsed", name) + } + } + for name, b := range map[string][]byte{ + "informational": unhexB("014064"), + "status 600": unhexB("014258"), + "request": good, + } { + if _, err := ParseResponse(b); err == nil { + t.Fatalf("%s parsed", name) + } + } +} + +func unhexB(s string) []byte { + b, _ := hex.DecodeString(s) + return b +} diff --git a/go/protocol/ohttp/ohttp.go b/go/protocol/ohttp/ohttp.go new file mode 100644 index 000000000..cb990d5c4 --- /dev/null +++ b/go/protocol/ohttp/ohttp.go @@ -0,0 +1,335 @@ +// Package ohttp is Oblivious HTTP (RFC 9458) for one suite: DHKEM(X25519, +// HKDF-SHA256), HKDF-SHA256 and AES-128-GCM, with the known-length Binary +// HTTP messages (RFC 9292) it carries. +// +// A box sends its anonymous daily energy report this way: sealed to FTW +// Cloud's gateway key and posted to an independent relay. The relay sees the +// box's address and a ciphertext of fixed size. The gateway sees the report +// and the relay's address. Neither sees both. +// +// HPKE is the standard library's crypto/hpke. This package adds only the +// framing RFC 9458 puts around it and the response key schedule, and it +// speaks one suite so that no message can pick a weaker one. +package ohttp + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/hpke" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "slices" +) + +// The one suite, by its HPKE ids. +const ( + KEMX25519 uint16 = 0x0020 + KDFHKDFSHA256 uint16 = 0x0001 + AEADAES128GCM uint16 = 0x0001 +) + +// Media types of RFC 9458, section 9. +const ( + RequestType = "message/ohttp-req" + ResponseType = "message/ohttp-res" + KeysType = "application/ohttp-keys" +) + +const ( + // keyLen is Npk and Nenc for X25519. + keyLen = 32 + // headerLen is the request header: key id, KEM, KDF and AEAD. + headerLen = 7 + // The response key schedule for AES-128-GCM: Nk 16, Nn 12, and a + // response nonce of max(Nn, Nk). + aeadKeyLen = 16 + aeadNonceLen = 12 + nonceLen = 16 + tagLen = 16 + + requestLabel = "message/bhttp request" + responseLabel = "message/bhttp response" +) + +var ( + ErrConfig = errors.New("ohttp: invalid key configuration") + ErrUnsupported = errors.New("ohttp: key configuration offers no supported suite") + ErrRequest = errors.New("ohttp: cannot decapsulate the request") + ErrResponse = errors.New("ohttp: cannot open the response") +) + +// Algorithms is one KDF and AEAD pair a key configuration offers. +type Algorithms struct { + KDF, AEAD uint16 +} + +var suite = Algorithms{KDF: KDFHKDFSHA256, AEAD: AEADAES128GCM} + +// KeyConfig is a gateway's public key and what it accepts with it +// (RFC 9458, section 3). +type KeyConfig struct { + ID uint8 + KEM uint16 + PublicKey []byte + Algorithms []Algorithms +} + +// Marshal writes the configuration as RFC 9458 section 3.1 lays it out. +func (c KeyConfig) Marshal() []byte { + b := make([]byte, 0, 3+len(c.PublicKey)+2+4*len(c.Algorithms)) + b = append(b, c.ID) + b = binary.BigEndian.AppendUint16(b, c.KEM) + b = append(b, c.PublicKey...) + b = binary.BigEndian.AppendUint16(b, uint16(4*len(c.Algorithms))) + for _, a := range c.Algorithms { + b = binary.BigEndian.AppendUint16(b, a.KDF) + b = binary.BigEndian.AppendUint16(b, a.AEAD) + } + return b +} + +// ParseKeyConfig reads exactly one configuration. Only the X25519 KEM is +// known, because a key length cannot be read for a KEM this package does not +// speak. +func ParseKeyConfig(b []byte) (KeyConfig, error) { + if len(b) < 3 { + return KeyConfig{}, ErrConfig + } + c := KeyConfig{ID: b[0], KEM: binary.BigEndian.Uint16(b[1:3])} + if c.KEM != KEMX25519 { + return KeyConfig{}, ErrUnsupported + } + b = b[3:] + if len(b) < keyLen+2 { + return KeyConfig{}, ErrConfig + } + c.PublicKey = bytes.Clone(b[:keyLen]) + n := int(binary.BigEndian.Uint16(b[keyLen:])) + b = b[keyLen+2:] + if n < 4 || n%4 != 0 || len(b) != n { + return KeyConfig{}, ErrConfig + } + for ; len(b) > 0; b = b[4:] { + c.Algorithms = append(c.Algorithms, Algorithms{KDF: binary.BigEndian.Uint16(b), AEAD: binary.BigEndian.Uint16(b[2:])}) + } + if _, err := hpke.DHKEM(ecdh.X25519()).NewPublicKey(c.PublicKey); err != nil { + return KeyConfig{}, ErrConfig + } + return c, nil +} + +// MarshalKeys writes application/ohttp-keys: each configuration after its +// two-byte length. +func MarshalKeys(configs ...KeyConfig) []byte { + var b []byte + for _, c := range configs { + m := c.Marshal() + b = binary.BigEndian.AppendUint16(b, uint16(len(m))) + b = append(b, m...) + } + return b +} + +// ParseKeys reads application/ohttp-keys. A configuration for another KEM is +// skipped, as RFC 9458 section 3.2 allows; a malformed one fails the whole +// list. +func ParseKeys(b []byte) ([]KeyConfig, error) { + var out []KeyConfig + for len(b) > 0 { + if len(b) < 2 { + return nil, ErrConfig + } + n := int(binary.BigEndian.Uint16(b)) + if len(b) < 2+n { + return nil, ErrConfig + } + c, err := ParseKeyConfig(b[2 : 2+n]) + switch { + case errors.Is(err, ErrUnsupported): + case err != nil: + return nil, err + default: + out = append(out, c) + } + b = b[2+n:] + } + return out, nil +} + +func header(id uint8) []byte { + h := []byte{id} + h = binary.BigEndian.AppendUint16(h, KEMX25519) + h = binary.BigEndian.AppendUint16(h, suite.KDF) + return binary.BigEndian.AppendUint16(h, suite.AEAD) +} + +func info(hdr []byte) []byte { + return append(append([]byte(requestLabel), 0), hdr...) +} + +// ClientContext opens the one response to one request. +type ClientContext struct { + enc []byte + sender *hpke.Sender +} + +// Encapsulate seals msg to the gateway that published cfg (RFC 9458, +// section 4.3). A configuration that does not offer this package's suite is +// refused, never downgraded. +func Encapsulate(cfg KeyConfig, msg []byte) ([]byte, *ClientContext, error) { + if cfg.KEM != KEMX25519 || !slices.Contains(cfg.Algorithms, suite) { + return nil, nil, ErrUnsupported + } + pk, err := hpke.DHKEM(ecdh.X25519()).NewPublicKey(cfg.PublicKey) + if err != nil { + return nil, nil, ErrConfig + } + hdr := header(cfg.ID) + enc, sender, err := hpke.NewSender(pk, hpke.HKDFSHA256(), hpke.AES128GCM(), info(hdr)) + if err != nil { + return nil, nil, err + } + ct, err := sender.Seal(nil, msg) + if err != nil { + return nil, nil, err + } + out := slices.Concat(hdr, enc, ct) + return out, &ClientContext{enc: enc, sender: sender}, nil +} + +// Open reads the gateway's answer (RFC 9458, section 4.4). +func (c *ClientContext) Open(encResp []byte) ([]byte, error) { + secret, err := c.sender.Export(responseLabel, nonceLen) + if err != nil { + return nil, ErrResponse + } + return openResponse(secret, c.enc, encResp) +} + +// Key is one private key a gateway decapsulates with, under its key id. +type Key struct { + ID uint8 + Private *ecdh.PrivateKey +} + +// Gateway decapsulates requests sealed to its keys. +type Gateway struct { + keys map[uint8]hpke.PrivateKey + configs []KeyConfig +} + +// NewGateway holds X25519 keys under distinct ids. Each is published with +// this package's one suite. +func NewGateway(keys ...Key) (*Gateway, error) { + g := &Gateway{keys: map[uint8]hpke.PrivateKey{}} + for _, k := range keys { + if k.Private == nil || k.Private.Curve() != ecdh.X25519() { + return nil, ErrConfig + } + if _, dup := g.keys[k.ID]; dup { + return nil, ErrConfig + } + priv, err := hpke.NewDHKEMPrivateKey(k.Private) + if err != nil { + return nil, ErrConfig + } + g.keys[k.ID] = priv + g.configs = append(g.configs, KeyConfig{ID: k.ID, KEM: KEMX25519, PublicKey: k.Private.PublicKey().Bytes(), Algorithms: []Algorithms{suite}}) + } + if len(g.keys) == 0 { + return nil, ErrConfig + } + return g, nil +} + +// Configs are the gateway's key configurations, in the order given. +func (g *Gateway) Configs() []KeyConfig { return slices.Clone(g.configs) } + +// ServerContext seals the one response to one request. +type ServerContext struct { + enc, secret []byte +} + +// Decapsulate opens a request (RFC 9458, section 4.3). Every failure is the +// same error, so an answer says nothing about which check refused it. +func (g *Gateway) Decapsulate(req []byte) ([]byte, *ServerContext, error) { + if len(req) < headerLen+keyLen+tagLen { + return nil, nil, ErrRequest + } + key, ok := g.keys[req[0]] + if !ok || !bytes.Equal(req[:headerLen], header(req[0])) { + return nil, nil, ErrRequest + } + hdr, enc, ct := req[:headerLen], req[headerLen:headerLen+keyLen], req[headerLen+keyLen:] + r, err := hpke.NewRecipient(enc, key, hpke.HKDFSHA256(), hpke.AES128GCM(), info(hdr)) + if err != nil { + return nil, nil, ErrRequest + } + msg, err := r.Open(nil, ct) + if err != nil { + return nil, nil, ErrRequest + } + secret, err := r.Export(responseLabel, nonceLen) + if err != nil { + return nil, nil, ErrRequest + } + return msg, &ServerContext{enc: bytes.Clone(enc), secret: secret}, nil +} + +// Seal encapsulates the response under a fresh nonce (RFC 9458, section 4.4). +func (s *ServerContext) Seal(resp []byte) ([]byte, error) { + nonce := make([]byte, nonceLen) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return sealResponse(s.secret, s.enc, nonce, resp) +} + +func responseAEAD(secret, enc, nonce []byte) (cipher.AEAD, []byte, error) { + prk, err := hkdf.Extract(sha256.New, secret, slices.Concat(enc, nonce)) + if err != nil { + return nil, nil, err + } + key, err := hkdf.Expand(sha256.New, prk, "key", aeadKeyLen) + if err != nil { + return nil, nil, err + } + iv, err := hkdf.Expand(sha256.New, prk, "nonce", aeadNonceLen) + if err != nil { + return nil, nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, err + } + aead, err := cipher.NewGCM(block) + return aead, iv, err +} + +func sealResponse(secret, enc, nonce, resp []byte) ([]byte, error) { + aead, iv, err := responseAEAD(secret, enc, nonce) + if err != nil { + return nil, err + } + return aead.Seal(bytes.Clone(nonce), iv, resp, nil), nil +} + +func openResponse(secret, enc, encResp []byte) ([]byte, error) { + if len(encResp) < nonceLen+tagLen { + return nil, ErrResponse + } + aead, iv, err := responseAEAD(secret, enc, encResp[:nonceLen]) + if err != nil { + return nil, ErrResponse + } + msg, err := aead.Open(nil, iv, encResp[nonceLen:], nil) + if err != nil { + return nil, ErrResponse + } + return msg, nil +} diff --git a/go/protocol/ohttp/ohttp_test.go b/go/protocol/ohttp/ohttp_test.go new file mode 100644 index 000000000..12f12386d --- /dev/null +++ b/go/protocol/ohttp/ohttp_test.go @@ -0,0 +1,247 @@ +package ohttp + +import ( + "bytes" + "crypto/ecdh" + "encoding/hex" + "errors" + "strings" + "testing" +) + +// RFC 9458, Appendix A. +const ( + rfcSecretKey = "3c168975674b2fa8e465970b79c8dcf09f1c741626480bd4c6162fc5b6a98e1a" + rfcKeyConfig = "01002031e1f05a740102115220e9af918f738674aec95f54db6e04eb705aae8e79815500080001000100010003" + rfcRequest = "00034745540568747470730b6578616d706c652e636f6d012f" + rfcInfo = "6d6573736167652f626874747020726571756573740001002000010001" + rfcEncRequest = "010020000100014b28f881333e7c164ffc499ad9796f877f4e1051ee6d31bad19dec96c208b4726374e469135906992e1268c594d2a10c695d858c40a026e7965e7d86b83dd440b2c0185204b4d63525" + rfcResponse = "0140c8" + rfcSecret = "62d87a6ba569ee81014c2641f52bea36" + rfcNonce = "c789e7151fcba46158ca84b04464910d" + rfcEncResp = "c789e7151fcba46158ca84b04464910d86f9013e404feea014e7be4a441f234f857fbd" +) + +func unhex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} + +func rfcGateway(t *testing.T) *Gateway { + t.Helper() + priv, err := ecdh.X25519().NewPrivateKey(unhex(t, rfcSecretKey)) + if err != nil { + t.Fatal(err) + } + g, err := NewGateway(Key{ID: 1, Private: priv}) + if err != nil { + t.Fatal(err) + } + return g +} + +func TestRFC9458KeyConfiguration(t *testing.T) { + cfg, err := ParseKeyConfig(unhex(t, rfcKeyConfig)) + if err != nil { + t.Fatal(err) + } + if cfg.ID != 1 || cfg.KEM != KEMX25519 || len(cfg.Algorithms) != 2 || + cfg.Algorithms[0] != (Algorithms{KDFHKDFSHA256, AEADAES128GCM}) || cfg.Algorithms[1] != (Algorithms{KDFHKDFSHA256, 0x0003}) { + t.Fatalf("parsed %+v", cfg) + } + if got := hex.EncodeToString(cfg.Marshal()); got != rfcKeyConfig { + t.Fatalf("marshal %s", got) + } + if got := rfcGateway(t).Configs()[0]; !bytes.Equal(got.PublicKey, cfg.PublicKey) || got.ID != 1 { + t.Fatalf("the secret key does not give the published key: %+v", got) + } + if got := hex.EncodeToString(info(header(1))); got != rfcInfo { + t.Fatalf("info %s", got) + } +} + +// The gateway opens the RFC's request, exports its secret, and seals the +// RFC's response byte for byte under the RFC's nonce. +func TestRFC9458GatewayVectors(t *testing.T) { + msg, ctx, err := rfcGateway(t).Decapsulate(unhex(t, rfcEncRequest)) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(msg) != rfcRequest { + t.Fatalf("request %x", msg) + } + if hex.EncodeToString(ctx.secret) != rfcSecret { + t.Fatalf("secret %x", ctx.secret) + } + resp, err := sealResponse(ctx.secret, ctx.enc, unhex(t, rfcNonce), unhex(t, rfcResponse)) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(resp) != rfcEncResp { + t.Fatalf("response %x", resp) + } + r, err := ParseRequest(msg) + if err != nil || r.Method != "GET" || r.Scheme != "https" || r.Authority != "example.com" || r.Path != "/" || len(r.Fields) != 0 || len(r.Content) != 0 { + t.Fatalf("binary request %+v %v", r, err) + } +} + +// The client half: its request header is the RFC's, and its response key +// schedule opens the RFC's encapsulated response. +func TestRFC9458ClientVectors(t *testing.T) { + enc := unhex(t, rfcEncRequest) + if got := hex.EncodeToString(header(1)); got != hex.EncodeToString(enc[:headerLen]) { + t.Fatalf("header %s", got) + } + resp, err := openResponse(unhex(t, rfcSecret), enc[headerLen:headerLen+keyLen], unhex(t, rfcEncResp)) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(resp) != rfcResponse { + t.Fatalf("response %x", resp) + } + r, err := ParseResponse(resp) + if err != nil || r.Status != 200 { + t.Fatalf("binary response %+v %v", r, err) + } + + cfg, err := ParseKeyConfig(unhex(t, rfcKeyConfig)) + if err != nil { + t.Fatal(err) + } + req, cctx, err := Encapsulate(cfg, unhex(t, rfcRequest)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(req[:headerLen], enc[:headerLen]) || len(req) != len(enc) { + t.Fatalf("request %x", req) + } + msg, sctx, err := rfcGateway(t).Decapsulate(req) + if err != nil || hex.EncodeToString(msg) != rfcRequest { + t.Fatalf("gateway read %x %v", msg, err) + } + sealed, err := sctx.Seal(unhex(t, rfcResponse)) + if err != nil { + t.Fatal(err) + } + got, err := cctx.Open(sealed) + if err != nil || hex.EncodeToString(got) != rfcResponse { + t.Fatalf("client read %x %v", got, err) + } +} + +func TestTheGatewayRefusesWhatItCannotOpen(t *testing.T) { + g := rfcGateway(t) + good := unhex(t, rfcEncRequest) + flip := func(i int) []byte { + b := bytes.Clone(good) + b[i] ^= 1 + return b + } + for name, req := range map[string][]byte{ + "empty": nil, + "short": good[:headerLen+keyLen], + "key id": flip(0), + "kem": flip(2), + "kdf": flip(4), + "aead": flip(6), + "enc": flip(headerLen), + "ciphertext": flip(len(good) - 1), + "truncated": good[:len(good)-1], + "extra bytes": append(bytes.Clone(good), 0), + } { + if _, _, err := g.Decapsulate(req); !errors.Is(err, ErrRequest) { + t.Fatalf("%s: %v", name, err) + } + } +} + +// A request sealed to one key cannot be read by a gateway holding another, +// even under the same key id. A box encrypts only to the configuration its +// release pins, so a key swapped in at the service reads nothing. +func TestASwappedKeyReadsNothing(t *testing.T) { + cfg, err := ParseKeyConfig(unhex(t, rfcKeyConfig)) + if err != nil { + t.Fatal(err) + } + req, _, err := Encapsulate(cfg, []byte("report")) + if err != nil { + t.Fatal(err) + } + other, err := ecdh.X25519().GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + g, err := NewGateway(Key{ID: 1, Private: other}) + if err != nil { + t.Fatal(err) + } + if _, _, err := g.Decapsulate(req); !errors.Is(err, ErrRequest) { + t.Fatalf("a different key opened the request: %v", err) + } +} + +func TestTheClientRefusesOtherSuitesAndForgedResponses(t *testing.T) { + cfg, err := ParseKeyConfig(unhex(t, rfcKeyConfig)) + if err != nil { + t.Fatal(err) + } + only := cfg + only.Algorithms = []Algorithms{{KDFHKDFSHA256, 0x0003}} + if _, _, err := Encapsulate(only, nil); !errors.Is(err, ErrUnsupported) { + t.Fatalf("ChaCha20 only: %v", err) + } + _, cctx, err := Encapsulate(cfg, []byte("x")) + if err != nil { + t.Fatal(err) + } + for _, forged := range [][]byte{nil, make([]byte, nonceLen+tagLen), unhex(t, rfcEncResp)} { + if _, err := cctx.Open(forged); !errors.Is(err, ErrResponse) { + t.Fatalf("opened %x: %v", forged, err) + } + } +} + +func TestKeyConfigurationsAreReadStrictly(t *testing.T) { + good := unhex(t, rfcKeyConfig) + for name, b := range map[string][]byte{ + "empty": nil, + "trailing byte": append(bytes.Clone(good), 0), + "short key": append(bytes.Clone(good[:3+31]), good[3+32:]...), + "no algorithms": append(bytes.Clone(good[:3+32]), 0, 0), + "odd length": append(bytes.Clone(good[:3+32]), 0, 6, 0, 1, 0, 1, 0, 1), + } { + if _, err := ParseKeyConfig(b); err == nil { + t.Fatalf("%s parsed", name) + } + } + p256 := bytes.Clone(good) + p256[2] = 0x10 + if _, err := ParseKeyConfig(p256); !errors.Is(err, ErrUnsupported) { + t.Fatalf("P-256: %v", err) + } + + cfg, _ := ParseKeyConfig(good) + list := MarshalKeys(KeyConfig{ID: 9, KEM: 0x0010, PublicKey: make([]byte, 65), Algorithms: []Algorithms{{1, 1}}}, cfg) + got, err := ParseKeys(list) + if err != nil || len(got) != 1 || !bytes.Equal(got[0].Marshal(), good) { + t.Fatalf("keys %+v %v", got, err) + } + if _, err := ParseKeys(list[:len(list)-1]); err == nil { + t.Fatal("a cut list parsed") + } + if _, err := NewGateway(); err == nil { + t.Fatal("a gateway without keys") + } + k, _ := ecdh.X25519().GenerateKey(nil) + if _, err := NewGateway(Key{ID: 1, Private: k}, Key{ID: 1, Private: k}); err == nil { + t.Fatal("two keys under one id") + } + if !strings.HasPrefix(RequestType, "message/") || KeysType != "application/ohttp-keys" { + t.Fatal("media types") + } +} diff --git a/go/protocol/reportwire/report.go b/go/protocol/reportwire/report.go new file mode 100644 index 000000000..a69557be4 --- /dev/null +++ b/go/protocol/reportwire/report.go @@ -0,0 +1,112 @@ +// Package reportwire is how the box's anonymous daily reports reach FTW +// Cloud: each report alone, as one padded inner request sealed with Oblivious +// HTTP (package ohttp) and posted to an independent relay. The gateway reads +// the report's schema and hands it to that report's decoder: the fleet report +// (package fleetwire) or the energy report (package energywire). +// +// Two reports, never one message. With a fleet of a few boxes a driver set +// and a price zone can point at one home, so energy sent beside them would +// belong to that home again. Each report leaves at its own random time. +package reportwire + +import ( + "encoding/base64" + "encoding/json" + "time" + + "github.com/srcfl/ftw/go/protocol/ohttp" +) + +// The one inner request. The gateway answers no other. +const ( + Method = "POST" + Scheme = "https" + Authority = "cloud.ftw.energy" + Path = "/v1/report" +) + +const ( + // RequestSize is every padded inner request and ResponseSize every padded + // inner response, so the relay sees one length whatever the report, its + // kind or the answer. The largest fleet report fits. + RequestSize = 1536 + ResponseSize = 16 +) + +// IDBytes is a report id's length. The box draws one at random for each +// report and keeps it if it ever sends that report again, so the gateway +// counts a report once however often it arrives (RFC 9458, section 6.5). +const IDBytes = 16 + +// Report is what one inner request carries besides its fixed parts: the +// report's random id, the UTC date the box sent it, and the report itself. +// The id names one report, never a box. +type Report struct { + ID []byte + Day string + Body []byte +} + +const ( + idField = "report-id" + dayField = "report-day" +) + +// Request is the padded inner request carrying one report. ok is false for +// an id or a day of the wrong form, and when the report does not fit, so a +// report never goes out at a length of its own. +func Request(r Report) (msg []byte, ok bool) { + if len(r.ID) != IDBytes || !isDate(r.Day) { + return nil, false + } + msg = ohttp.Request{Method: Method, Scheme: Scheme, Authority: Authority, Path: Path, + Fields: []ohttp.Field{{Name: idField, Value: base64.RawURLEncoding.EncodeToString(r.ID)}, {Name: dayField, Value: r.Day}}, + Content: r.Body}.Marshal(RequestSize) + return msg, len(msg) == RequestSize +} + +// Read opens an inner request, or returns false for anything but the one +// padded request with an id and a day. +func Read(msg []byte) (Report, bool) { + req, err := ohttp.ParseRequest(msg) + if err != nil || len(msg) != RequestSize || req.Method != Method || req.Scheme != Scheme || + req.Authority != Authority || req.Path != Path || len(req.Fields) != 2 || + req.Fields[0].Name != idField || req.Fields[1].Name != dayField { + return Report{}, false + } + id, err := base64.RawURLEncoding.DecodeString(req.Fields[0].Value) + if err != nil || len(id) != IDBytes || !isDate(req.Fields[1].Value) { + return Report{}, false + } + return Report{ID: id, Day: req.Fields[1].Value, Body: req.Content}, true +} + +// Fresh reports whether a report sent on day still counts at now: sent today +// or yesterday, UTC. An older one is refused, so a captured request cannot be +// posted again on a later day, when its id is no longer remembered. +func Fresh(day string, now time.Time) bool { + today := now.UTC().Format(time.DateOnly) + return day == today || day == now.UTC().AddDate(0, 0, -1).Format(time.DateOnly) +} + +func isDate(s string) bool { + t, err := time.Parse(time.DateOnly, s) + return err == nil && t.Format(time.DateOnly) == s +} + +// Schema reads only a report's schema, to choose its decoder. The decoder +// still checks every byte. +func Schema(body []byte) string { + var head struct { + Schema string `json:"schema"` + } + if json.Unmarshal(body, &head) != nil { + return "" + } + return head.Schema +} + +// Response is the padded inner answer. +func Response(status int) []byte { + return ohttp.Response{Status: status}.Marshal(ResponseSize) +} diff --git a/go/protocol/reportwire/report_test.go b/go/protocol/reportwire/report_test.go new file mode 100644 index 000000000..7afe0ec8e --- /dev/null +++ b/go/protocol/reportwire/report_test.go @@ -0,0 +1,95 @@ +package reportwire + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol/energywire" + "github.com/srcfl/ftw/go/protocol/fleetwire" + "github.com/srcfl/ftw/go/protocol/ohttp" +) + +// The largest report of either kind fills the same padded request, so the +// relay cannot tell a fleet report from an energy report, or one box's from +// another's, by its length. +func TestEveryReportHasOneLength(t *testing.T) { + drivers := make([]string, 16) + for i := range drivers { + drivers[i] = strings.Repeat(string(rune('a'+i)), 64) + } + fleet, _ := json.Marshal(fleetwire.Report{Schema: fleetwire.Schema, Version: "v1234.1234.1234-beta.1234", Channel: "unknown", + Drivers: drivers, Battery: "30+", Zone: strings.Repeat("Z", 16), Age: "unknown"}) + energy, _ := json.Marshal(energywire.Report{Schema: energywire.Schema, Date: "2026-10-23", PVGeneration: 1000, GridImport: 1000, + GridExport: 1000, BatteryCharge: 1000, BatteryDischarge: 1000, VehicleCharge: 1000}) + small, _ := json.Marshal(energywire.Report{Schema: energywire.Schema, Date: "2026-10-23"}) + id := []byte("0123456789abcdef") + for _, body := range [][]byte{fleet, energy, small} { + msg, ok := Request(Report{ID: id, Day: "2026-10-24", Body: body}) + if !ok || len(msg) != RequestSize { + t.Fatalf("%d-byte report padded to %d", len(body), len(msg)) + } + got, ok := Read(msg) + if !ok || string(got.Body) != string(body) || string(got.ID) != string(id) || got.Day != "2026-10-24" || Schema(got.Body) == "" { + t.Fatalf("read back %+v", got) + } + } + if Schema(fleet) != fleetwire.Schema || Schema(energy) != energywire.Schema || Schema([]byte("x")) != "" { + t.Fatal("schema") + } + for name, r := range map[string]Report{ + "oversized": {ID: id, Day: "2026-10-24", Body: make([]byte, RequestSize)}, + "short id": {ID: id[:15], Day: "2026-10-24", Body: small}, + "no day": {ID: id, Body: small}, + "loose day": {ID: id, Day: "2026-10-4", Body: small}, + } { + if _, ok := Request(r); ok { + t.Fatalf("%s was padded", name) + } + } + if len(Response(204)) != ResponseSize { + t.Fatal("response padding") + } +} + +func TestOnlyTheOneInnerRequestIsRead(t *testing.T) { + fields := []ohttp.Field{{Name: "report-id", Value: "MDEyMzQ1Njc4OWFiY2RlZg"}, {Name: "report-day", Value: "2026-10-24"}} + good := ohttp.Request{Method: Method, Scheme: Scheme, Authority: Authority, Path: Path, Fields: fields, Content: []byte("{}")} + if _, ok := Read(good.Marshal(RequestSize)); !ok { + t.Fatal("the one request was refused") + } + other := func(f func(*ohttp.Request)) []byte { + r := good + f(&r) + return r.Marshal(RequestSize) + } + for name, msg := range map[string][]byte{ + "another path": other(func(r *ohttp.Request) { r.Path = "/v1/energy/report" }), + "another method": other(func(r *ohttp.Request) { r.Method = "PUT" }), + "another host": other(func(r *ohttp.Request) { r.Authority = "example.com" }), + "plain http": other(func(r *ohttp.Request) { r.Scheme = "http" }), + "another header": other(func(r *ohttp.Request) { r.Fields = append(r.Fields, ohttp.Field{Name: "x-home", Value: "1"}) }), + "no id": other(func(r *ohttp.Request) { r.Fields = r.Fields[1:] }), + "short id": other(func(r *ohttp.Request) { r.Fields = []ohttp.Field{{Name: "report-id", Value: "MDEy"}, fields[1]} }), + "bad day": other(func(r *ohttp.Request) { r.Fields = []ohttp.Field{fields[0], {Name: "report-day", Value: "yesterday"}} }), + "swapped": other(func(r *ohttp.Request) { r.Fields = []ohttp.Field{fields[1], fields[0]} }), + "not padded": good.Marshal(0), + "padded more": good.Marshal(RequestSize + 1), + "a response": Response(200), + } { + if _, ok := Read(msg); ok { + t.Fatalf("%s was read", name) + } + } +} + +// A report counts on the day it was sent and the next, UTC, and no later. +func TestOnlyAFreshReportCounts(t *testing.T) { + now := time.Date(2026, 10, 24, 0, 30, 0, 0, time.UTC) + for day, want := range map[string]bool{"2026-10-24": true, "2026-10-23": true, "2026-10-22": false, "2026-10-25": false, "x": false} { + if Fresh(day, now) != want { + t.Fatalf("%s: fresh %v", day, !want) + } + } +} diff --git a/web/app-link-tab.test.mjs b/web/app-link-tab.test.mjs index 5dca9e744..4b798b00b 100644 --- a/web/app-link-tab.test.mjs +++ b/web/app-link-tab.test.mjs @@ -91,16 +91,16 @@ describe("the app tab", () => { assert.match(render({}), /id="app-link-pair"[^>]*disabled/); }); - it("keeps fleet statistics with the app, but independent of its switch", () => { + it("keeps statistics with the app, but independent of its switch", () => { const appWithFleet = loadTab(true); const config = { app_link: { enabled: false } }; const html = appWithFleet.render({ config }); - assert.match(html, /