Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/energy-report-direct.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

The daily energy report now goes straight to FTW Cloud, as the fleet report has since 8 November. It waited for an independent relay that does not exist yet, so no box sent one and stats.ftw.energy showed no energy. FTW Cloud sees the box's address while the request is open and keeps neither the address nor the report. Settings → FTW app → Anonymous statistics says where the report goes.
13 changes: 13 additions & 0 deletions docs/adr/0008-ftw-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,19 @@ build with the relay sends the fleet report to FTW Cloud only through it.
release after the first that pins the relay. A collector the household set
in `fleet_ping.endpoint` is still posted to directly.

**Amended 8 and 12 November 2026.** No relay exists yet. On 8 November
Fredrik decided that every box that shares sends its fleet report straight to
`/v1/fleet`, not only one that said yes. On 12 November he decided the same
for the energy report, so that stats.ftw.energy can show energy: a build
without a relay posts it to `POST /v1/energy` (`statsrelay.DirectURL`). That
route takes the same padded inner request, unsealed, checks its id and day
the same way and adds it to the same totals. FTW Cloud then sees the box's
address while the request is open. It writes no request log and keeps
neither the address nor the report, but until a relay exists Sourceful alone
could match an energy report to a fleet report by address and time; that is
a rule in code. Once a release pins a relay, both reports go through it as
described above.

**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
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,8 +643,9 @@ counts health only from reports that carry it.
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
`https://cloud.ftw.energy/v1/fleet` from every box that shares, and the energy
report to `https://cloud.ftw.energy/v1/energy` (ADR 0008, amended 8 and 12
November 2026). 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
Expand Down
4 changes: 2 additions & 2 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,6 @@ 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
Oblivious HTTP relay once one exists and straight to FTW Cloud until then
(ADR 0008, "Anonymous daily reports and one statistics answer"). Statistics-only rosters already running keep working until the
owner turns statistics off.
2 changes: 1 addition & 1 deletion go/cmd/ftw/energy_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

// 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.
// would send. A build with no relay sends it straight to FTW Cloud.
func startEnergyReport(ctx context.Context, cfg *config.Config, cfgMu *sync.RWMutex, st *state.Store, answer statsAnswer) *energyreport.Sender {
if st == nil {
return nil
Expand Down
13 changes: 7 additions & 6 deletions go/internal/api/api_fleet_ping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func (dayLedger) LoadEnergyHistoryContext(_ context.Context, q state.EnergyHisto

// 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.
// relay sends it straight to FTW Cloud and says so.
func TestFleetPingShowsTheEnergyReportItWouldSend(t *testing.T) {
cfg := &config.Config{FleetPing: &config.FleetPing{Enabled: true}}
srv := fleetPingServer(t, cfg)
Expand All @@ -197,17 +197,18 @@ func TestFleetPingShowsTheEnergyReportItWouldSend(t *testing.T) {
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"`
Relay string `json:"relay"`
Endpoint string `json:"endpoint"`
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 {
if e.Relay != "" || e.Endpoint != "https://cloud.ftw.energy/v1/energy" || !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)
}
}
32 changes: 19 additions & 13 deletions go/internal/energyreport/energyreport.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
// 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.
// Package energyreport sends one anonymous energy report a day to FTW Cloud.
//
// 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:
// (package energywire). Package statsrelay carries it. With a relay pinned in
// this release, it seals the report to FTW Cloud's gateway key 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.
//
// Until a relay exists, the report goes straight to FTW Cloud, as the fleet
// report does (Fredrik, 12 November 2026): every box that shares reports.
// FTW Cloud then sees the box's address while the request is open, adds the
// sums the same way and keeps neither the address nor 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
Expand Down Expand Up @@ -59,7 +63,8 @@ type Options struct {
Ledger Ledger
KV KV

// Relay defaults to statsrelay.Pinned. Tests set a fake relay.
// Relay defaults to statsrelay.Default: the pinned relay, or FTW Cloud
// directly. Tests set a fake.
Relay *statsrelay.Client
Logger *slog.Logger

Expand Down Expand Up @@ -88,7 +93,7 @@ func New(opts Options) (*Sender, error) {
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()
s.relay = statsrelay.Default()
}
if s.enabled == nil {
s.enabled = func() bool { return false }
Expand All @@ -111,10 +116,12 @@ func New(opts Options) (*Sender, error) {

// 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 is the relay it goes through, empty when it goes straight to
// FTW Cloud.
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.
// Endpoint is where the box posts it: the relay, or FTW Cloud.
Endpoint string `json:"endpoint"`
// Sending is whether this box sends at all: the owner's answer now.
Sending bool `json:"sending"`
// Day is the closed UTC day it describes.
Day string `json:"day"`
Expand All @@ -127,16 +134,15 @@ 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()
p.Relay, p.Endpoint, p.Sending = s.relay.Relay(), s.relay.Endpoint(), 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.
// Run sends once a day until ctx ends.
func (s *Sender) Run(ctx context.Context) {
if s.relay == nil {
return
Expand Down
33 changes: 19 additions & 14 deletions go/internal/energyreport/energyreport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/statsrelay"
"github.com/srcfl/ftw/go/internal/statsrelay/statsrelaytest"
)

Expand Down Expand Up @@ -95,22 +96,26 @@ func TestNothingIsSentWithoutTheOwnersAnswer(t *testing.T) {
}
}

// 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")
// Without a relay the report goes straight to FTW Cloud, and Settings says
// where.
func TestWithoutARelayTheReportGoesStraightToFTWCloud(t *testing.T) {
s := newSender(t, Options{Enabled: func() bool { return true }})
if statsrelay.Pinned() == nil {
if p := s.Preview(context.Background()); p.Relay != "" || p.Endpoint != statsrelay.DirectURL || !p.Sending || p.Day == "" {
t.Fatalf("preview %+v", p)
}
}
if l.n != 0 {
t.Fatal("read the ledger with nowhere to send")

client, cloud := statsrelaytest.NewDirect(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)
if got := cloud.Energy(); len(got) != 1 || got[0].Date != "2026-10-23" || got[0].GridImport != 24 {
t.Fatalf("FTW Cloud got %+v", got)
}
if p := s.Preview(context.Background()); p.Relay != "" || p.Day == "" {
t.Fatalf("preview %+v", p)
if kv[lastDayKey] != "2026-10-23" {
t.Fatalf("kv %v", kv)
}
}

Expand Down
94 changes: 82 additions & 12 deletions go/internal/statsrelay/statsrelay.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
// 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.
//
// Until a release names a relay, the energy report goes straight to FTW
// Cloud instead (Direct), as the fleet report goes to /v1/fleet: every box
// that shares reports (Fredrik, 8 and 12 November 2026). FTW Cloud then sees
// the box's address while the request is open, the way any website does, and
// keeps neither the address nor the report.
package statsrelay

import (
Expand All @@ -32,8 +38,9 @@ import (
// 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.
// Empty until that relay exists: both reports then go straight to FTW
// Cloud, the energy report to DirectURL and the fleet report to
// /v1/fleet.
RelayURL = ""
// GatewayKey is FTW Cloud's gateway key configuration (RFC 9458,
// section 3), base64url, as
Expand All @@ -42,19 +49,29 @@ var (
// 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 = ""
// DirectURL is FTW Cloud's route for the energy report from a build
// without a relay. It takes the same padded inner request the gateway
// opens, unsealed.
DirectURL = "https://cloud.ftw.energy/v1/energy"
)

// DirectType is the direct request's media type: the inner request is a
// Binary HTTP message (RFC 9292).
const DirectType = "message/bhttp"

const (
sendTimeout = 30 * time.Second
maxResponseBytes = 1 << 10
)

// Client posts sealed reports to one relay under one gateway key.
// Client posts reports to one place: sealed to one relay under one gateway
// key, or, when direct, unsealed to FTW Cloud.
type Client struct {
relay string
key ohttp.KeyConfig
http *http.Client
now func() time.Time
url string
key ohttp.KeyConfig
direct bool
http *http.Client
now func() time.Time
}

// Pinned is this build's client, or nil when the build names no usable relay
Expand All @@ -67,6 +84,15 @@ func Pinned() *Client {
return New(relay, key, nil)
}

// Default is the relay when this build pins one, and FTW Cloud directly when
// it does not.
func Default() *Client {
if c := Pinned(); c != nil {
return c
}
return NewDirect(DirectURL, nil)
}

func pinned(relay, key string) (string, ohttp.KeyConfig, bool) {
raw, err := base64.RawURLEncoding.DecodeString(key)
if err != nil || relay == "" || key == "" {
Expand All @@ -91,15 +117,25 @@ func pinned(relay, key string) (string, ohttp.KeyConfig, bool) {

// 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 {
return &Client{url: relay, key: key, http: httpClient(client), now: time.Now}
}

// NewDirect is a client that posts each report, unsealed, to endpoint. Tests
// point it at a fake FTW Cloud.
func NewDirect(endpoint string, client *http.Client) *Client {
return &Client{url: endpoint, direct: true, http: httpClient(client), now: time.Now}
}

func httpClient(client *http.Client) *http.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.
// A redirect would resend the 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}
return client
}

// WithClock makes c stamp reports with the day now reads. Tests pin it to
Expand All @@ -109,8 +145,16 @@ func (c *Client) WithClock(now func() time.Time) *Client {
return c
}

// Relay is the relay's URL, for Settings.
func (c *Client) Relay() string { return c.relay }
// Relay is the relay's URL, for Settings, and empty when c posts directly.
func (c *Client) Relay() string {
if c.direct {
return ""
}
return c.url
}

// Endpoint is where c posts: the relay, or FTW Cloud when direct.
func (c *Client) Endpoint() string { return c.url }

// 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.
Expand All @@ -127,13 +171,16 @@ func (c *Client) Send(ctx context.Context, body []byte) error {
if !ok {
return errors.New("statsrelay: the report does not fit the padded request")
}
if c.direct {
return c.sendDirect(ctx, inner)
}
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))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(sealed))
if err != nil {
return err
}
Expand Down Expand Up @@ -167,3 +214,26 @@ func (c *Client) Send(ctx context.Context, body []byte) error {
}
return nil
}

// sendDirect posts the inner request to FTW Cloud. Only a 204 counts as
// delivered; the answer carries nothing else the box would read.
func (c *Client) sendDirect(ctx context.Context, inner []byte) error {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(inner))
if err != nil {
return err
}
req.Header.Set("Content-Type", DirectType)
req.Header.Set("User-Agent", "ftw-report")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("statsrelay: FTW Cloud answered %d", resp.StatusCode)
}
return nil
}
Loading
Loading