diff --git a/CLAUDE.md b/CLAUDE.md index 04b8019..184a269 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ maintains it. grant serves a copy; SQLite holds only the home id, the box key, the signed roster, owners' suspensions, tombstones, APNs registrations, the hashes of extension tokens when each away box's alert is due, browser notice sent markers and - anonymous daily fleet totals. Browser endpoints and box-encrypted offline + anonymous daily fleet and energy totals. Browser endpoints and box-encrypted offline notices live only in the signed roster. Nothing logs addresses. - **A phone can only narrow, or talk to its own box.** On `/v1/app` it can diff --git a/README.md b/README.md index 3358fca..94db4c5 100644 --- a/README.md +++ b/README.md @@ -452,9 +452,12 @@ with `go list -deps`, so no control code can reach this build. only (AGPL-3.0-only). See [LICENSE](LICENSE) and [CONTRIBUTING.md](CONTRIBUTING.md). -Anonymous box reports use `POST /v1/fleet`. It accepts only the seven -`ftw.fleet/1` fields and reduces each report into bounded daily counts for -90 days. It stores no raw report, address or home id. An operator can read +Anonymous box reports use `POST /v1/fleet`, or, from a Core release that +names a relay, `POST /v1/ohttp` (see "Anonymous daily reports" below). The +direct route sees the box's address; it stays for older releases and can go +one stable Core release after the first that pins the relay. Either way it +accepts only the seven `ftw.fleet/1` fields and reduces each report into +bounded daily counts for 90 days. It stores no raw report, address or home id. An operator can read `/fleet/stats` only on the loopback metrics listener, with `docker exec ftw-cloud-ftwcloudd-1 ftwcloudd fleet`. These counts measure reports, not unique boxes. @@ -512,3 +515,73 @@ consumers replace the old archive rather than adding both. It needs no hourly energy activation. Updated Core sends it only after the current project statistics answer; legacy versions retain their existing daily-report setting. Averaging seven complete UTC days gives an estimate of active reporting installations, never people or all installations ever made. + +## Anonymous daily reports + +Statistics from a box are two anonymous daily reports, each sent alone and at +its own random time, never in one message. With FTW Cloud they are part of +Cloud; without it they are on until the owner turns them off on the box. The +rule lives in Core (`cloudlink.SharesStatistics` in srcfl/ftw). + +- The fleet report (`ftw.fleet/1`): version, channel, drivers, battery size, + price zone and install age, into the counts `/v1/fleet/public` publishes. +- The energy report (`ftw.energy/1`): 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 box's energy ledger is skipped. + +They never travel together because, with a fleet of a few boxes, a driver set +and a price zone can point at one home, and energy beside them would then be +that home's. + +From a Core release that names a relay, the box seals each report with +Oblivious HTTP (RFC 9458) to the gateway key its release pins, and posts it +to an independent relay ([deploy/ohttp.md](deploy/ohttp.md)). The relay sees +the box's address and a ciphertext of fixed length (1,591 bytes). +`POST /v1/ohttp` (`internal/gateway`) sees the report and the relay's +address. It reads the one padded inner request, chooses the decoder by +schema, adds the report to its own day totals and answers a sealed 204, or +400 for anything else: exact fields, and for energy at most 1,000 kWh each +and a date in the last three closed days. Each request carries a random +report id and the UTC date it was sent. The gateway refuses one sent before +yesterday, and keeps a hash of each id under its date for three days, in a +table with no time or arrival order, so a replay or a retry counts once and +is still answered 204 (RFC 9458, section 6.5). Beyond that it keeps nothing +per report: no row, logged body or address. Each kind has its own rate +budget, spent only by a report that opened and decoded, so junk cannot +silence real reports. A day takes at most 100,000 reports of each kind, and +a body has 10 seconds to arrive. +`GET /.well-known/ohttp-gateway` serves the key configuration for the relay +operator and for anyone checking the pin. + +`GET /v1/energy/public` lists the closed dates of the last 30 days, newest +first: the report count and the six sums. A date appears only once no report +may name it any more, so two readings never differ by one report. A date with +fewer than 5 reports shows null, as `/v1/fleet/public` does. So does a sum that +between one and four of them had any of: five reports with one car is that +car's charge. A sum nobody had any of shows 0. Rows, which hold for each sum +how many reports had any of it, go after 30 days. These are reports, not +homes. + +`/v1/fleet/public` also lists closed days only, so whoever forwarded a +report, the relay above all, cannot poll the view and see which count it +moved. + +Limits, plainly. The floor of five counts reports, not boxes; Fredrik +accepted this on 24 October 2026. A 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. The +relay and Sourceful together could join reports to addresses by time. This +service also holds readings from homes with the readable copy on, including +48 hours of history tiles, from which the same daily sums could be computed; +the gateway does not do this and keeps nothing to match with, but that is a +rule in code, not one the cryptography enforces. + +Open for Fredrik's lawyer: reports that are anonymous to Sourceful through +OHTTP should be outside GDPR, but 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. diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index 46a2b01..6a0e1ca 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -3,6 +3,7 @@ // reads and never controls. See README.md. // // ftwcloudd keygen -out service.key make the service key; prints its public half +// ftwcloudd keygen -ohttp -out ohttp.key make a gateway key; prints the configuration Core pins // ftwcloudd keygen -noise -out noise.key make the Noise key phones dial; prints its public half // ftwcloudd metrics print the running service's counts, from inside its container // ftwcloudd health https://host/healthz check /healthz over TCP and HTTP/3 @@ -38,6 +39,7 @@ import ( "github.com/srcfl/ftw-cloud/internal/apns" "github.com/srcfl/ftw-cloud/internal/appsession" "github.com/srcfl/ftw-cloud/internal/fleet" + "github.com/srcfl/ftw-cloud/internal/gateway" "github.com/srcfl/ftw-cloud/internal/keys" "github.com/srcfl/ftw-cloud/internal/mcp" "github.com/srcfl/ftw-cloud/internal/metrics" @@ -89,6 +91,7 @@ func serve(args []string) error { stateDir := fs.String("state", "/state", "directory for the registry and certificates") keyPath := fs.String("service-key", "/run/secrets/ftw-cloud-service.key", "service key, PEM") noisePath := fs.String("noise-key", "/run/secrets/ftw-cloud-noise.key", "Noise key phones dial, PEM; without it /v1/app is off") + ohttpPath := fs.String("ohttp-key", "/run/secrets/ftw-cloud-ohttp.key", "Oblivious HTTP gateway keys, PEM; without them /v1/ohttp is off") certFile := fs.String("tls-cert", "", "certificate file, instead of ACME (local tests)") keyFile := fs.String("tls-key", "", "certificate key file, with -tls-cert") email := fs.String("acme-email", "", "contact for the certificate authority") @@ -122,6 +125,14 @@ func serve(args []string) error { } else if err != nil { return err } + // The anonymous reports' gateway takes its keys the same way. Without them + // no report reaches here, and the public totals still answer. + ohttpKeys, err := loadOHTTPKeys(*ohttpPath) + if errors.Is(err, os.ErrNotExist) { + log.Warn("no OHTTP key: the gateway for the anonymous reports is off until one is installed", "path", *ohttpPath) + } else if err != nil { + return err + } if *statsPolicy != "" && *statsPolicy != cloudwire.StatsPolicy { return fmt.Errorf("unknown statistics policy") } @@ -164,9 +175,13 @@ func serve(args []string) error { if err != nil { return err } + gatewaySvc, err := gateway.New(reg, ohttpKeys) + if err != nil { + return err + } tokens := token.New(st, reg, log) tokens.Shards = shards - srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Fleet: fleetSvc, Statistics: usageSvc, Log: log} + srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Fleet: fleetSvc, Gateway: gatewaySvc, Statistics: usageSvc, Log: log} // Assistants: the authorization server, with agent keys in ftwkeysd. agentKeys := keys.Dial(*keysSocket) @@ -386,7 +401,8 @@ func serve(args []string) error { pub, _ := cloudwire.PublicKeyBytesOf(&key.PublicKey) log.Info("ftwcloudd listening", "addr", *listen, "h3", srv.AltSvc != "", "quic_sessions", door != nil && door.Box != nil, "shard", shards.Self(), "shards", shards.Shards(), - "service_key", base64.RawURLEncoding.EncodeToString(pub), "noise_key", base64.RawURLEncoding.EncodeToString(noise.Public())) + "service_key", base64.RawURLEncoding.EncodeToString(pub), "noise_key", base64.RawURLEncoding.EncodeToString(noise.Public()), + "ohttp_keys", ohttpPins(ohttpKeys)) err = httpSrv.ListenAndServeTLS("", "") if err != nil && !errors.Is(err, http.ErrServerClosed) { return err @@ -549,10 +565,14 @@ func keygen(args []string) error { fs := flag.NewFlagSet("keygen", flag.ExitOnError) out := fs.String("out", "", "file to write the key to") noise := fs.Bool("noise", false, "make the X25519 key phones dial instead of the P-256 service key") + gatewayKey := fs.Bool("ohttp", false, "make an Oblivious HTTP gateway key; prints the key configuration Core pins") _ = fs.Parse(args) if *out == "" { return errors.New("keygen: -out is required") } + if *gatewayKey { + return keygenOHTTP(*out) + } if *noise { x, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { diff --git a/cmd/ftwcloudd/ohttp.go b/cmd/ftwcloudd/ohttp.go new file mode 100644 index 0000000..06363cc --- /dev/null +++ b/cmd/ftwcloudd/ohttp.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "crypto/ecdh" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "os" + "strings" + + "github.com/srcfl/ftw/go/protocol/ohttp" +) + +// The Oblivious HTTP gateway's keys (deploy/ohttp.md). Core pins the public +// half of each as a key configuration, so a key here that no release pins +// opens nothing. + +// ohttpKeyID is the first byte of SHA-256 of the public key, so a key's id +// needs no second file. +func ohttpKeyID(pub []byte) uint8 { + sum := sha256.Sum256(pub) + return sum[0] +} + +// loadOHTTPKeys reads the gateway's X25519 keys: PKCS #8 PEM, one block per +// key. A second block lets a key rotate across a Core release. +func loadOHTTPKeys(path string) (*ohttp.Gateway, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("ohttp key: %w", err) + } + // Each key is one whole PEM block, with nothing but blank space around + // it. Anything else, such as a block cut short in a rotation, is refused + // wherever it sits: pem.Decode would skip it and load the rest. + const begin, end = "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----" + var keys []ohttp.Key + for rest := bytes.TrimSpace(raw); len(rest) > 0; rest = bytes.TrimSpace(rest) { + stop := bytes.Index(rest, []byte(end)) + if !bytes.HasPrefix(rest, []byte(begin)) || stop < 0 || bytes.Count(rest[:stop], []byte("-----BEGIN")) != 1 { + return nil, fmt.Errorf("ohttp key: %s holds data that is not a whole PKCS #8 key", path) + } + block, after := pem.Decode(rest[:stop+len(end)]) + if block == nil || len(bytes.TrimSpace(after)) > 0 { + return nil, fmt.Errorf("ohttp key: %s holds data that is not a whole PKCS #8 key", path) + } + rest = rest[stop+len(end):] + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("ohttp key: %w", err) + } + x, ok := parsed.(*ecdh.PrivateKey) + if !ok || x.Curve() != ecdh.X25519() { + return nil, fmt.Errorf("ohttp key: %s holds a key that is not X25519", path) + } + keys = append(keys, ohttp.Key{ID: ohttpKeyID(x.PublicKey().Bytes()), Private: x}) + } + if len(keys) == 0 { + return nil, fmt.Errorf("ohttp key: %s holds no key", path) + } + gw, err := ohttp.NewGateway(keys...) + if err != nil { + return nil, fmt.Errorf("ohttp key: %s holds two keys under one id; make a new one", path) + } + return gw, nil +} + +// ohttpPins is each key configuration as Core pins it, comma separated, for +// the start line. Empty when the gateway is off. +func ohttpPins(gw *ohttp.Gateway) string { + if gw == nil { + return "" + } + var pins []string + for _, c := range gw.Configs() { + pins = append(pins, base64.RawURLEncoding.EncodeToString(c.Marshal())) + } + return strings.Join(pins, ",") +} + +// keygenOHTTP writes a new gateway key, never over an existing file, and +// prints its configuration: the string statsrelay.GatewayKey in srcfl/ftw +// pins. For a rotation, put the new block after the old one in the same +// parameter; ftwcloudd refuses two keys under one id, and then a new key is +// needed. +func keygenOHTTP(path string) error { + x, err := ecdh.X25519().GenerateKey(nil) + if err != nil { + return err + } + der, err := x509.MarshalPKCS8PrivateKey(x) + if err != nil { + return err + } + if err := writePEM(path, "PRIVATE KEY", der); err != nil { + return err + } + gw, err := ohttp.NewGateway(ohttp.Key{ID: ohttpKeyID(x.PublicKey().Bytes()), Private: x}) + if err != nil { + return err + } + fmt.Println(ohttpPins(gw)) + return nil +} diff --git a/cmd/ftwcloudd/ohttp_test.go b/cmd/ftwcloudd/ohttp_test.go new file mode 100644 index 0000000..81662f9 --- /dev/null +++ b/cmd/ftwcloudd/ohttp_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "encoding/base64" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/srcfl/ftw/go/protocol/ohttp" +) + +// keygen prints the configuration Core pins, and ftwcloudd loads the same +// key back from the file under the same id. +func TestAGatewayKeyLoadsAsThePinSays(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ohttp.key") + pin := captureStdout(t, func() error { return keygenOHTTP(path) }) + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(pin)) + if err != nil { + t.Fatal(err) + } + cfg, err := ohttp.ParseKeyConfig(raw) + if err != nil { + t.Fatal(err) + } + gw, err := loadOHTTPKeys(path) + if err != nil { + t.Fatal(err) + } + if got := gw.Configs(); len(got) != 1 || !bytes.Equal(got[0].Marshal(), raw) || cfg.ID != ohttpKeyID(cfg.PublicKey) { + t.Fatalf("loaded %+v, pinned %+v", got, cfg) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 { + t.Fatalf("key file mode %v", info.Mode()) + } + if err := keygenOHTTP(path); err == nil { + t.Fatal("keygen wrote over a key") + } + + // A rotation is a second block in the same file. + second := filepath.Join(dir, "second.key") + captureStdout(t, func() error { return keygenOHTTP(second) }) + a, _ := os.ReadFile(path) + b, _ := os.ReadFile(second) + both := filepath.Join(dir, "both.key") + if err := os.WriteFile(both, append(a, b...), 0o600); err != nil { + t.Fatal(err) + } + if gw, err := loadOHTTPKeys(both); err != nil && !strings.Contains(err.Error(), "one id") || err == nil && len(gw.Configs()) != 2 { + t.Fatalf("two keys: %v", err) + } + if pins := ohttpPins(nil); pins != "" { + t.Fatal(pins) + } + + // A second block cut short, as a rotation pasted in part leaves it, is + // refused rather than quietly left unloaded. + for name, data := range map[string][]byte{ + "cut after a whole key": append(append([]byte{}, a...), b[:len(b)/2]...), + "cut before a whole key": append(append([]byte{}, b[:len(b)/2]...), a...), + "text between keys": append(append(append([]byte{}, a...), []byte("note\n")...), b...), + } { + cut := filepath.Join(dir, "cut.key") + if err := os.WriteFile(cut, data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadOHTTPKeys(cut); err == nil || !strings.Contains(err.Error(), "not a whole PKCS #8 key") { + t.Fatalf("%s: %v", name, err) + } + } +} + +func TestOnlyX25519GatewayKeysLoad(t *testing.T) { + dir := t.TempDir() + if _, err := loadOHTTPKeys(filepath.Join(dir, "missing.key")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing: %v", err) + } + service := filepath.Join(dir, "service.key") + captureStdout(t, func() error { return keygen([]string{"-out", service}) }) + empty := filepath.Join(dir, "empty.key") + _ = os.WriteFile(empty, []byte("no key here\n"), 0o600) + for _, p := range []string{service, empty} { + if _, err := loadOHTTPKeys(p); err == nil { + t.Fatalf("%s loaded", p) + } + } +} + +func captureStdout(t *testing.T, f func() error) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + runErr := f() + os.Stdout = old + _ = w.Close() + out, _ := io.ReadAll(r) + if runErr != nil { + t.Fatal(runErr) + } + return string(out) +} diff --git a/deploy/README.md b/deploy/README.md index c1c603f..b36ad15 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -381,8 +381,9 @@ Cloud data; the images on `ghcr.io` hold only code. ## Not here yet -The APNs key ([apns.md](apns.md)) and the Noise key phones dial -([noise.md](noise.md)) come from Parameter Store. HTTP/3 needs UDP 443 in the +The APNs key ([apns.md](apns.md)), the Noise key phones dial +([noise.md](noise.md)) and the anonymous reports' gateway key +([ohttp.md](ohttp.md)) come from Parameter Store. HTTP/3 needs UDP 443 in the security group ([http3.md](http3.md)). Widget tokens need no secret: the registry holds their hashes. Statistics (C8) have their own [review](../privacy/project-statistics-dpia.md) and diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index b2d1c8e..fbf1ee9 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -131,7 +131,7 @@ if ! [[ $user =~ ^[0-9]+:[0-9]+$ ]]; then exit 1 fi vol=$(docker volume inspect -f '{{.Mountpoint}}' ftw-cloud_ftw-cloud-state 2>/dev/null || true) -for p in "$vol" /run/ftw-cloud/service.key /run/ftw-cloud/apns /run/ftw-cloud/noise; do +for p in "$vol" /run/ftw-cloud/service.key /run/ftw-cloud/apns /run/ftw-cloud/noise /run/ftw-cloud/ohttp; do if [ -n "$p" ] && [ -e "$p" ]; then chown -R "$user" "$p" fi diff --git a/deploy/compose.yml b/deploy/compose.yml index e936d46..cf23143 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -28,6 +28,7 @@ services: - -service-key=/run/secrets/ftw-cloud-service.key - -acme-email=${FTW_CLOUD_ACME_EMAIL:-} - -noise-key=/run/secrets/noise/noise.key + - -ohttp-key=/run/secrets/ohttp/ohttp.key - -keys-socket=/run/ftw-cloud-keys/keys.sock environment: # /source publishes it, so anyone can compare it with what CI built. @@ -50,6 +51,10 @@ services: # boot by deploy/noise.sh. Empty means /v1/app is off, and the # service runs. - /run/ftw-cloud/noise:/run/secrets/noise:ro + # The anonymous reports' Oblivious HTTP gateway key, read from Parameter + # Store into tmpfs at boot by deploy/ohttp.sh. Empty means /v1/ohttp + # is off, and the service runs. + - /run/ftw-cloud/ohttp:/run/secrets/ohttp:ro # The keys part's socket (deploy/keys.md). Without ftwkeysd it holds # nothing, and assistants cannot connect. - /run/ftw-cloud-keys:/run/ftw-cloud-keys diff --git a/deploy/ohttp.md b/deploy/ohttp.md new file mode 100644 index 0000000..d978edd --- /dev/null +++ b/deploy/ohttp.md @@ -0,0 +1,112 @@ +# The anonymous reports' gateway key and relay + +A box sends two anonymous reports a day, a fleet report and an energy report, +each alone and sealed with Oblivious HTTP (RFC 9458) to this service's +gateway key, and posts each to a relay that someone other than Sourceful +runs. The relay forwards it to `POST /v1/ohttp`. The +relay sees the box's address but not the report; this service sees the +report but not the address. Without the key, `/v1/ohttp` answers 501 and the +public totals still answer. + +The private key lives like the Noise key ([noise.md](noise.md)): made on a +machine signed in to the account, put straight into Parameter Store, and +read into tmpfs at every boot. It is never in the repository, the image, the +volume or a log. A Core release pins the public half, so boxes never ask +this service for a key. + +## 1. Make the key (once) + +From a checkout of this repository: + +```bash +go run ./cmd/ftwcloudd keygen -ohttp -out /tmp/ftw-cloud-ohttp.key +``` + +It writes the private key and prints the key configuration, base64url, 55 +characters. Keep it: it is the pin for Core in step 5, and the relay +operator may ask for it. + +## 2. Put it in Parameter Store (once) + +In a terminal signed in to the FTW Cloud account (976075258475): + +```bash +aws ssm put-parameter --region eu-north-1 --name /ftw-cloud/ohttp-key --type SecureString --value file:///tmp/ftw-cloud-ohttp.key +rm /tmp/ftw-cloud-ohttp.key +``` + +## 3. Give it to the host (once per host) + +`deploy/ohttp.sh` installs `ftw-cloud-ohttp.service`, which reads the +parameter into `/run/ftw-cloud/ohttp/` before Docker starts. `compose.yml` +mounts that directory read-only. Run it through SSM, then redeploy: + +```bash +aws ssm send-command --region eu-north-1 --instance-ids i-0ecac4cbc6b5f8adc \ + --document-name AWS-RunShellScript \ + --parameters 'commands=["bash /opt/ftw-cloud/deploy/ohttp.sh","/usr/local/sbin/ftw-cloud-deploy"]' +``` + +The start line, `ftwcloudd listening … ohttp_keys=…`, shows the +configuration from step 1. From anywhere: + +```bash +curl -s https://cloud.ftw.energy/.well-known/ohttp-gateway | base64 +curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: message/ohttp-req' \ + --data-binary 'not a report' https://cloud.ftw.energy/v1/ohttp +``` + +The first prints the configuration with a two-byte length in front. The +second prints 400, because the bytes do not open under the key; without the +key it prints 501. + +## 4. Ask a relay operator (once) + +The relay must be run by someone other than Sourceful. Two that sell one: + +- **Cloudflare OHTTP Relay** (formerly Privacy Gateway). Enterprise only, in + closed beta: ask through https://www.cloudflare.com/lp/privacy-edge/. + Sourceful already uses Cloudflare, so this is the shorter path. +- **Fastly OHTTP Relay**, through Fastly sales. + +Send them: + +- **Gateway URL:** `https://cloud.ftw.energy/v1/ohttp` +- **Key configuration:** `https://cloud.ftw.energy/.well-known/ohttp-gateway` + (`application/ohttp-keys`), or the string from step 1. The suite is + DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM. +- **Traffic:** `POST` with `message/ohttp-req`, always 1,591 bytes; answers + are `message/ohttp-res`, always 48 bytes. Two requests per box per day, at + separate random times. + +Ask them to confirm, in writing: + +- they pass the gateway no client address or other client header + (`Forwarded`, `X-Forwarded-For`, `CF-Connecting-IP`, `True-Client-IP`, + `User-Agent`), only the body and its content type; +- what they log about the client and for how long. Cloudflare's published + terms keep the address, the time and request metadata for about 124 days; +- that they keep no request body. + +They hand back the **relay URL** the boxes post to. Cloudflare's form is +`https://privacy-relay.cloudflare.com/`. + +## 5. Pin both in Core + +In srcfl/ftw, `go/internal/statsrelay/statsrelay.go`: `RelayURL` takes the +relay URL from step 4 and `GatewayKey` the string from step 1. Boxes on the +release that carries both send both reports through the relay; older +releases send no energy report and post the fleet report straight to +`/v1/fleet`. + +## Rotating + +Make a new key with step 1 into a new file, and put both PEM blocks, old +then new, in the one parameter. ftwcloudd loads both and refuses two keys +under one id; then make the new one again. Ship a Core release that pins +the new configuration. Once boxes have moved to it, drop the old block. + +## Access + +The host role needs nothing new, as for the Noise key: see +[apns.md](apns.md), "Access". diff --git a/deploy/ohttp.sh b/deploy/ohttp.sh new file mode 100755 index 0000000..abca56d --- /dev/null +++ b/deploy/ohttp.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Give ftwcloudd the Oblivious HTTP gateway key. Run as root on the FTW Cloud host +# through SSM, once the key is in Parameter Store +# (deploy/ohttp.md). Safe to re-run. +# +# The key rests only in Parameter Store, as a SecureString under the +# account's aws/ssm key. At every boot, before Docker starts, a unit reads it +# into tmpfs, where compose.yml mounts it into the container. It is never +# written to the volume or the image, and nothing prints it. The host role +# reads it with what it already has, as for the APNs key (deploy/apns.md). +set -euo pipefail + +cat > /usr/local/sbin/ftw-cloud-ohttp <<'FETCH' +#!/bin/bash +# Read the OHTTP gateway key from Parameter Store into tmpfs for ftwcloudd. +# Without the parameter ftwcloudd runs and /v1/ohttp stays off. +set -euo pipefail +export AWS_PAGER="" +umask 077 +dir=/run/ftw-cloud/ohttp +install -d -m 0700 /run/ftw-cloud +new=$(mktemp -d /run/ftw-cloud/ohttp.XXXXXX) +trap 'rm -rf "$new"' EXIT +# At boot the network and the instance's credentials may lag a little. +for try in $(seq 30); do + if err=$(aws ssm get-parameter --region eu-north-1 --name /ftw-cloud/ohttp-key --with-decryption \ + --query Parameter.Value --output text 2>&1 >"$new/ohttp.key"); then + break + fi + case "$err" in *ParameterNotFound*) + echo "no OHTTP key in Parameter Store; /v1/ohttp stays off" + exit 0 ;; + esac + [ "$try" -lt 30 ] || { echo "$err" >&2; exit 1; } + sleep 2 +done +grep -q '^-----BEGIN PRIVATE KEY-----$' "$new/ohttp.key" +# ftw-cloud-own gives it to the user the image runs as, before compose starts. +chown -R 65532:65532 "$new" +chmod 0400 "$new/ohttp.key" +chmod 0500 "$new" +rm -rf "$dir" +mv "$new" "$dir" +trap - EXIT +echo "OHTTP key in place" +FETCH +chmod 0755 /usr/local/sbin/ftw-cloud-ohttp + +cat > /etc/systemd/system/ftw-cloud-ohttp.service <<'UNIT' +[Unit] +Description=FTW Cloud OHTTP gateway key into tmpfs +Before=docker.service +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/ftw-cloud-ohttp + +[Install] +WantedBy=docker.service +UNIT + +systemctl daemon-reload +systemctl enable ftw-cloud-ohttp.service +systemctl restart ftw-cloud-ohttp.service diff --git a/go.mod b/go.mod index 79eb397..9e1ade0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261023165843-21e3d16946f1 + github.com/srcfl/ftw/go/protocol v0.0.0-20261024082233-5cf4a80689fe golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 35d7169..9403679 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261023165843-21e3d16946f1 h1:OCneVMFORT+ejZeiWAQ+66tHIN0IMXX7MtLtNbF6m1U= -github.com/srcfl/ftw/go/protocol v0.0.0-20261023165843-21e3d16946f1/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261024082233-5cf4a80689fe h1:fVahq1vonhGzVCiTyW3sxZDNul8Nn0VP8YBDUHhVEms= +github.com/srcfl/ftw/go/protocol v0.0.0-20261024082233-5cf4a80689fe/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/fleet/fleet.go b/internal/fleet/fleet.go index d5a1102..664d789 100644 --- a/internal/fleet/fleet.go +++ b/internal/fleet/fleet.go @@ -3,6 +3,7 @@ package fleet import ( "encoding/json" + "errors" "io" "net/http" "sync" @@ -12,6 +13,9 @@ import ( "github.com/srcfl/ftw/go/protocol/fleetwire" ) +// bodyDeadline bounds reading a report. +var bodyDeadline = 10 * time.Second + type Service struct { reg *registry.Registry now func() time.Time @@ -31,6 +35,11 @@ func New(reg *registry.Registry) (*Service, error) { } // ServeHTTP accepts reports only. Public requests never read totals. +// +// This is the direct path, which sees the box's address: for boxes on a Core +// release that names no relay. A release that pins one sends the fleet report +// through /v1/ohttp (package gateway) into the same totals. Remove this route +// one stable Core release after the first that pins the relay. func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodPost { @@ -42,11 +51,22 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) return } + // A slow sender cannot hold the connection open. + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Now().Add(bodyDeadline)) data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, fleetwire.MaxBodyBytes)) if err != nil { - w.WriteHeader(http.StatusRequestEntityTooLarge) + // The rest of the body is never read: close rather than wait for it. + w.Header().Set("Connection", "close") + var large *http.MaxBytesError + if errors.As(err, &large) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + } else { + w.WriteHeader(http.StatusRequestTimeout) + } return } + _ = rc.SetReadDeadline(time.Time{}) report, err := fleetwire.Decode(data) if err != nil { w.WriteHeader(http.StatusBadRequest) diff --git a/internal/fleet/fleet_test.go b/internal/fleet/fleet_test.go index 4ee230a..42af44c 100644 --- a/internal/fleet/fleet_test.go +++ b/internal/fleet/fleet_test.go @@ -1,10 +1,13 @@ package fleet import ( + "bufio" "bytes" "encoding/json" + "fmt" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw/go/protocol/fleetwire" + "net" "net/http" "net/http/httptest" "path/filepath" @@ -90,3 +93,32 @@ func TestReportsBecomeOnlyBoundedDailyTotalsAcrossARestart(t *testing.T) { } // The only extra table holds date and aggregate JSON, not source addresses. } + +// A sender that stops halfway through its body is cut off. +func TestASlowFleetBodyIsCutOff(t *testing.T) { + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) + if err != nil { + t.Fatal(err) + } + defer reg.Close() + svc, err := New(reg) + if err != nil { + t.Fatal(err) + } + old := bodyDeadline + bodyDeadline = 200 * time.Millisecond + t.Cleanup(func() { bodyDeadline = old }) + srv := httptest.NewServer(svc) + defer srv.Close() + conn, err := net.Dial("tcp", srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _, _ = fmt.Fprintf(conn, "POST /v1/fleet HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: 1000\r\n\r\n{") + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil || resp.StatusCode != http.StatusRequestTimeout { + t.Fatalf("slow body: %v %v", resp, err) + } +} diff --git a/internal/fleet/public.go b/internal/fleet/public.go index e12ea63..1b879b9 100644 --- a/internal/fleet/public.go +++ b/internal/fleet/public.go @@ -9,7 +9,9 @@ import ( ) // Public exports only cells already large enough for the project dashboard. -// A day below the minimum has a null count and no dimension labels. +// A day below the minimum has a null count and no dimension labels. Only +// closed UTC days are listed: today's running counts would let whoever just +// forwarded a report, such as the relay, see which count it moved. func (s *Service) Public(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=60") if r.Method != http.MethodGet { @@ -24,8 +26,12 @@ func (s *Service) Public(w http.ResponseWriter, r *http.Request) { // Five since 23 October 2026 (Fredrik): the fleet is small and the // reports carry no home id. A day and each label below it stay hidden. const minimum = 5 + today := s.now().UTC().Format(time.DateOnly) days := make([]map[string]any, 0, len(view.Days)) for _, d := range view.Days { + if d.Date >= today { + continue + } row := map[string]any{"date": d.Date, "reports": nil} for key, values := range map[string]map[string]int{"ftw_versions": d.Versions, "channels": d.Channels, "drivers": d.Drivers, "battery_kwh": d.Battery, "price_zones": d.Zones, "install_age": d.Age} { safe := map[string]int{} diff --git a/internal/fleet/public_test.go b/internal/fleet/public_test.go index ec90ea4..b618ddd 100644 --- a/internal/fleet/public_test.go +++ b/internal/fleet/public_test.go @@ -39,6 +39,12 @@ func TestPublicFleetHidesSmallDaysAndEachSmallLabel(t *testing.T) { } return v } + // Today is still open, so its running counts are not listed: whoever + // forwarded a report could see which count it moved. + if days := get()["days"].([]any); len(days) != 0 { + t.Fatalf("today listed: %v", days) + } + svc.now = func() time.Time { return now.Add(24 * time.Hour) } v := get() d := v["days"].([]any)[0].(map[string]any) if d["reports"] != nil || len(d["ftw_versions"].(map[string]any)) != 0 { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go new file mode 100644 index 0000000..cdc50b0 --- /dev/null +++ b/internal/gateway/gateway.go @@ -0,0 +1,266 @@ +// Package gateway is the Oblivious HTTP gateway (RFC 9458) for the box's two +// anonymous daily reports, and the public view of the energy totals. +// +// A box seals each report alone to the gateway key and posts it to an +// independent relay, which forwards it here. The gateway sees the report and +// the relay's address, never the box's. It reads the report's schema, adds a +// fleet report to the fleet's day totals and an energy report to the +// energy's, and keeps nothing else: no row, logged body or address per +// report. +// +// A report cannot prove that a real box sent it. Bounds per field and a cap +// per date keep junk to noise. Privacy Pass-style tokens would be the fix. +package gateway + +import ( + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "sync" + "time" + + "github.com/srcfl/ftw-cloud/internal/registry" + "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" +) + +const ( + // maxRequest is a sealed request, 1,591 bytes (header, key, padded + // message and tag), with room to spare. + maxRequest = 2 << 10 + // Minimum is the group size for a public date, as /v1/fleet/public's. + Minimum = 5 + // rate and burst are each report kind's budget, spent only by a report + // that opened and decoded, so junk cannot use up the real reports'. + // Every request arrives from the relay, so there is no address worth + // keeping apart. + rate = 5.0 + burst = 120.0 +) + +// bodyDeadline bounds reading a request body, so a slow sender cannot hold a +// connection open. +var bodyDeadline = 10 * time.Second + +type Service struct { + reg *registry.Registry + // gw is nil on a host without a gateway key; the gateway routes then say + // so and the public view still answers. + gw *ohttp.Gateway + keys []byte + now func() time.Time + + fleet, energy budget +} + +// budget is a token bucket. +type budget struct { + mu sync.Mutex + at time.Time + tokens float64 +} + +func New(reg *registry.Registry, gw *ohttp.Gateway) (*Service, error) { + if err := reg.FleetInit(); err != nil { + return nil, err + } + if err := reg.EnergyInit(); err != nil { + return nil, err + } + if err := reg.ReportsInit(); err != nil { + return nil, err + } + s := &Service{reg: reg, gw: gw, now: time.Now} + s.fleet.tokens, s.energy.tokens = burst, burst + if err := s.Prune(time.Now()); err != nil { + return nil, err + } + if gw != nil { + s.keys = ohttp.MarshalKeys(gw.Configs()...) + } + return s, nil +} + +// ServeHTTP is the gateway, POST /v1/ohttp. A request it cannot open gets a +// plain 400. One it opens gets a sealed answer: 204 when the report is +// added or was counted already, 400 when it is not one valid report, 429 +// when its kind's budget or its day is full. +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if s.gw == nil { + http.Error(w, "not available yet", http.StatusNotImplemented) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if media, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); media != ohttp.RequestType { + w.WriteHeader(http.StatusUnsupportedMediaType) + return + } + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Now().Add(bodyDeadline)) + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequest)) + if err != nil { + // The rest of the body is never read: close rather than wait for it. + w.Header().Set("Connection", "close") + var large *http.MaxBytesError + if errors.As(err, &large) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + } else { + w.WriteHeader(http.StatusRequestTimeout) + } + return + } + _ = rc.SetReadDeadline(time.Time{}) + msg, sealer, err := s.gw.Decapsulate(body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + answer, err := sealer.Seal(reportwire.Response(s.add(msg))) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", ohttp.ResponseType) + _, _ = w.Write(answer) +} + +// add reads the one request the gateway answers and adds its report to the +// day totals of its kind, once: a report whose id was counted already is +// answered 204, adds nothing and spends no budget, so a replay or a retry is +// harmless and cannot crowd out real reports. +func (s *Service) add(msg []byte) int { + now := s.now() + report, ok := reportwire.Read(msg) + if !ok || !reportwire.Fresh(report.Day, now) { + return http.StatusBadRequest + } + switch seen, err := s.reg.ReportSeen(report.Day, report.ID); { + case err != nil: + return http.StatusServiceUnavailable + case seen: + return http.StatusNoContent + } + var added, repeat bool + var err error + switch reportwire.Schema(report.Body) { + case fleetwire.Schema: + fleet, decodeErr := fleetwire.Decode(report.Body) + if decodeErr != nil { + return http.StatusBadRequest + } + if !s.fleet.allow(now) { + return http.StatusTooManyRequests + } + added, repeat, err = s.reg.FleetPutOnce(report.Day, report.ID, fleet, now) + case energywire.Schema: + energy, decodeErr := energywire.Decode(report.Body, now) + if decodeErr != nil { + return http.StatusBadRequest + } + if !s.energy.allow(now) { + return http.StatusTooManyRequests + } + added, repeat, err = s.reg.EnergyAddOnce(report.Day, report.ID, energy) + default: + return http.StatusBadRequest + } + switch { + case err != nil: + return http.StatusServiceUnavailable + case repeat: + return http.StatusNoContent + case !added: + return http.StatusTooManyRequests + } + return http.StatusNoContent +} + +// Keys serves the gateway's key configuration at +// /.well-known/ohttp-gateway (RFC 9540). Boxes do not read it: a release pins +// the key. It is here for the relay's operator and for anyone checking the +// pin. +func (s *Service) Keys(w http.ResponseWriter, r *http.Request) { + if s.gw == nil { + http.Error(w, "not available yet", http.StatusNotImplemented) + return + } + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Header().Set("Content-Type", ohttp.KeysType) + _, _ = w.Write(s.keys) +} + +// Public is GET /v1/energy/public: the closed dates of the last EnergyDays, +// newest first. A date shows only once no report may name it any more, so +// two readings never differ by one report. A date with fewer than Minimum +// reports shows null, and so does a sum that between one and Minimum-1 of +// them had any of: five reports with one car is that car's charge. A sum +// nobody had any of shows 0. +func (s *Service) Public(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=60") + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + now := s.now().UTC() + today := now.Truncate(24 * time.Hour) + first, last := today.AddDate(0, 0, -registry.EnergyDays), today.AddDate(0, 0, -energywire.AcceptDays-1) + totals, err := s.reg.EnergyTotals(first.Format(time.DateOnly), last.Format(time.DateOnly)) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + days := []map[string]any{} + for d := last; !d.Before(first); d = d.AddDate(0, 0, -1) { + date := d.Format(time.DateOnly) + day, row := totals[date], map[string]any{"date": date, "reports": nil} + for i, name := range energywire.Fields { + row[name] = nil + if day.Reports >= Minimum && (day.Had[i] == 0 || day.Had[i] >= Minimum) { + row[name] = day.Sums[i] + } + } + if day.Reports >= Minimum { + row["reports"] = day.Reports + } + days = append(days, row) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"schema": "ftw.energy-public/1", "meaning": "daily reports, not homes", + "observed_at": now.Format(time.RFC3339), "minimum": Minimum, "days": days}) +} + +// Prune drops energy totals and report ids past their days. +func (s *Service) Prune(now time.Time) error { + if err := s.reg.EnergyPrune(now); err != nil { + return err + } + return s.reg.ReportsPrune(now) +} + +func (b *budget) allow(now time.Time) bool { + b.mu.Lock() + defer b.mu.Unlock() + if !b.at.IsZero() { + b.tokens = min(burst, b.tokens+max(0, now.Sub(b.at).Seconds())*rate) + } + b.at = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go new file mode 100644 index 0000000..e7ff290 --- /dev/null +++ b/internal/gateway/gateway_test.go @@ -0,0 +1,389 @@ +package gateway + +import ( + "bufio" + "bytes" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/registry" + "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" +) + +var now = time.Date(2026, 10, 24, 9, 0, 0, 0, time.UTC) + +const good = `{"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 newService(t *testing.T) (*Service, *registry.Registry, ohttp.KeyConfig) { + t.Helper() + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reg.Close() }) + k, err := ecdh.X25519().GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + gw, err := ohttp.NewGateway(ohttp.Key{ID: 9, Private: k}) + if err != nil { + t.Fatal(err) + } + s, err := New(reg, gw) + if err != nil { + t.Fatal(err) + } + s.now = func() time.Time { return now } + return s, reg, gw.Configs()[0] +} + +// inner is one report as a box sends it: a fresh id, sent today. +func inner(content string) ohttp.Request { + return innerWith(content, newID(), "2026-10-24") +} + +func innerWith(content string, id []byte, day string) ohttp.Request { + return ohttp.Request{Method: reportwire.Method, Scheme: reportwire.Scheme, Authority: reportwire.Authority, Path: reportwire.Path, + Fields: []ohttp.Field{{Name: "report-id", Value: base64.RawURLEncoding.EncodeToString(id)}, {Name: "report-day", Value: day}}, + Content: []byte(content)} +} + +func newID() []byte { + id := make([]byte, reportwire.IDBytes) + _, _ = rand.Read(id) + return id +} + +const fleet = `{"schema":"ftw.fleet/1","ftw_version":"v0.146.0-beta.1","channel":"beta","drivers":["pixii","pixii_pv"],"battery_kwh":"5-15","price_zone":"SE4","install_age":"1-6m"}` + +func post(s *Service, body []byte, contentType string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/ohttp", bytes.NewReader(body)) + r.Header.Set("Content-Type", contentType) + r.RemoteAddr = "198.51.100.8:443" + s.ServeHTTP(w, r) + return w +} + +// send seals msg as a box would and returns the gateway's inner status. +func send(t *testing.T, s *Service, cfg ohttp.KeyConfig, msg []byte) int { + t.Helper() + sealed, reader, err := ohttp.Encapsulate(cfg, msg) + if err != nil { + t.Fatal(err) + } + w := post(s, sealed, ohttp.RequestType) + if w.Code != http.StatusOK || w.Header().Get("Content-Type") != ohttp.ResponseType || w.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("outer %d %v", w.Code, w.Header()) + } + plain, err := reader.Open(w.Body.Bytes()) + if err != nil { + t.Fatal(err) + } + resp, err := ohttp.ParseResponse(plain) + if err != nil || len(plain) != reportwire.ResponseSize { + t.Fatalf("inner %x %v", plain, err) + } + return resp.Status +} + +func TestAReportAddsToItsDate(t *testing.T) { + s, reg, cfg := newService(t) + for range 2 { + if got := send(t, s, cfg, inner(good).Marshal(reportwire.RequestSize)); got != http.StatusNoContent { + t.Fatalf("status %d", got) + } + } + totals, err := reg.EnergyTotals("2026-10-23", "2026-10-23") + if d := totals["2026-10-23"]; err != nil || d.Reports != 2 || d.Sums != [6]int64{24, 60, 8, 18, 16, 0} { + t.Fatalf("%+v %v", d, err) + } +} + +// A fleet report through the gateway lands in the fleet's day totals, the +// ones /v1/fleet/public reads, and nothing of it in the energy totals. +func TestAFleetReportAddsToTheFleetTotals(t *testing.T) { + s, reg, cfg := newService(t) + if got := send(t, s, cfg, inner(fleet).Marshal(reportwire.RequestSize)); got != http.StatusNoContent { + t.Fatalf("status %d", got) + } + view, err := reg.FleetView(now) + if err != nil || len(view.Days) != 1 || view.Days[0].Reports != 1 || view.Days[0].Zones["SE4"] != 1 { + t.Fatalf("fleet %+v %v", view, err) + } + if totals, _ := reg.EnergyTotals("2026-01-01", "2026-12-31"); len(totals) != 0 { + t.Fatalf("a fleet report reached the energy totals: %v", totals) + } + var r fleetwire.Report + _ = json.Unmarshal([]byte(fleet), &r) + if r.Schema != fleetwire.Schema { + t.Fatal("fixture") + } +} + +// Anything but the one padded request with one valid report gets a sealed +// 400 and adds nothing. +func TestJunkIsRefusedAndAddsNothing(t *testing.T) { + s, reg, cfg := newService(t) + pad := reportwire.RequestSize + other := func(f func(*ohttp.Request)) []byte { + r := inner(good) + f(&r) + return r.Marshal(pad) + } + for name, msg := range map[string][]byte{ + "another path": other(func(r *ohttp.Request) { r.Path = "/v1/fleet" }), + "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" }), + "a header": other(func(r *ohttp.Request) { r.Fields = []ohttp.Field{{Name: "x-home", Value: "1"}} }), + "not padded": inner(good).Marshal(0), + "padded more": inner(good).Marshal(pad + 1), + "extra field": inner(strings.TrimSuffix(good, "}") + `,"home":"a"}`).Marshal(pad), + "too much solar": inner(strings.Replace(good, `"pv_generation_kwh":12`, `"pv_generation_kwh":1001`, 1)).Marshal(pad), + "negative": inner(strings.Replace(good, `:12,`, `:-12,`, 1)).Marshal(pad), + "today": inner(strings.Replace(good, "2026-10-23", "2026-10-24", 1)).Marshal(pad), + "four days back": inner(strings.Replace(good, "2026-10-23", "2026-10-20", 1)).Marshal(pad), + "other schema": inner(strings.Replace(good, "ftw.energy/1", "ftw.energy/2", 1)).Marshal(pad), + "no schema": inner(`{"date":"2026-10-23"}`).Marshal(pad), + "energy and fleet in one": inner(strings.TrimSuffix(fleet, "}") + `,"pv_generation_kwh":12}`).Marshal(pad), + "bad fleet": inner(strings.Replace(fleet, `"SE4"`, `"myhouse"`, 1)).Marshal(pad), + "a response": ohttp.Response{Status: 200, Content: []byte(good)}.Marshal(pad), + "empty": nil, + } { + if got := send(t, s, cfg, msg); got != http.StatusBadRequest { + t.Fatalf("%s: %d", name, got) + } + } + sealed, _, _ := ohttp.Encapsulate(cfg, inner(good).Marshal(pad)) + tampered := bytes.Clone(sealed) + tampered[len(tampered)-1] ^= 1 + for name, c := range map[string]struct { + body []byte + ctype string + code int + }{ + "tampered": {tampered, ohttp.RequestType, http.StatusBadRequest}, + "random bytes": {bytes.Repeat([]byte{9}, 400), ohttp.RequestType, http.StatusBadRequest}, + "plain JSON": {[]byte(good), "application/json", http.StatusUnsupportedMediaType}, + "oversized": {make([]byte, maxRequest+1), ohttp.RequestType, http.StatusRequestEntityTooLarge}, + } { + w := post(s, c.body, c.ctype) + if w.Code != c.code || w.Header().Get("Content-Type") == ohttp.ResponseType { + t.Fatalf("%s: %d", name, w.Code) + } + } + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/ohttp", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET %d", w.Code) + } + if totals, _ := reg.EnergyTotals("2026-01-01", "2026-12-31"); len(totals) != 0 { + t.Fatalf("junk reached the totals: %v", totals) + } + if view, _ := reg.FleetView(now); len(view.Days) != 0 { + t.Fatalf("junk reached the fleet totals: %+v", view) + } +} + +// A replayed request, or a retry of one report, counts once and is still +// answered 204, so neither the relay nor a lost answer moves a total. +func TestAReportCountsOnceHoweverOftenItArrives(t *testing.T) { + s, reg, cfg := newService(t) + sealed, _, _ := ohttp.Encapsulate(cfg, inner(good).Marshal(reportwire.RequestSize)) + for i := range 7 { + w := post(s, sealed, ohttp.RequestType) + if w.Code != http.StatusOK { + t.Fatalf("replay %d: %d", i, w.Code) + } + } + id := newID() + for range 3 { + if got := send(t, s, cfg, innerWith(fleet, id, "2026-10-24").Marshal(reportwire.RequestSize)); got != http.StatusNoContent { + t.Fatalf("retry: %d", got) + } + } + totals, _ := reg.EnergyTotals("2026-10-23", "2026-10-23") + view, _ := reg.FleetView(now) + if totals["2026-10-23"].Reports != 1 || len(view.Days) != 1 || view.Days[0].Reports != 1 { + t.Fatalf("energy %+v fleet %+v", totals, view) + } +} + +// A repeat is answered before any budget is spent, so one captured request +// sent many times cannot crowd out the real reports. +func TestARepeatSpendsNoBudget(t *testing.T) { + s, reg, cfg := newService(t) + sealed, _, _ := ohttp.Encapsulate(cfg, inner(fleet).Marshal(reportwire.RequestSize)) + for i := range 2 * int(burst) { + if w := post(s, sealed, ohttp.RequestType); w.Code != http.StatusOK { + t.Fatalf("repeat %d: %d", i, w.Code) + } + } + if got := send(t, s, cfg, inner(fleet).Marshal(reportwire.RequestSize)); got != http.StatusNoContent { + t.Fatalf("a new report after repeats: %d", got) + } + if view, _ := reg.FleetView(now); len(view.Days) != 1 || view.Days[0].Reports != 2 { + t.Fatalf("fleet %+v", view) + } +} + +// A request sent before yesterday is refused, so a captured one cannot be +// posted again once its id is forgotten. Yesterday's still counts. +func TestAnOldRequestIsRefused(t *testing.T) { + s, _, cfg := newService(t) + if got := send(t, s, cfg, innerWith(fleet, newID(), "2026-10-22").Marshal(reportwire.RequestSize)); got != http.StatusBadRequest { + t.Fatalf("two days old: %d", got) + } + if got := send(t, s, cfg, innerWith(fleet, newID(), "2026-10-23").Marshal(reportwire.RequestSize)); got != http.StatusNoContent { + t.Fatalf("yesterday: %d", got) + } +} + +// Junk does not spend a budget, and one kind's reports do not spend the +// other's. +func TestJunkSpendsNoBudget(t *testing.T) { + s, _, cfg := newService(t) + for range 3 * int(burst) { + if w := post(s, bytes.Repeat([]byte{9}, 400), ohttp.RequestType); w.Code != http.StatusBadRequest { + t.Fatalf("junk: %d", w.Code) + } + } + if got := send(t, s, cfg, inner(good).Marshal(reportwire.RequestSize)); got != http.StatusNoContent { + t.Fatalf("a real report after junk: %d", got) + } + for i := range int(burst) { + if got := send(t, s, cfg, inner(good).Marshal(reportwire.RequestSize)); got != http.StatusNoContent && i < int(burst)-1 { + t.Fatalf("energy %d: %d", i, got) + } + } + if got := send(t, s, cfg, inner(good).Marshal(reportwire.RequestSize)); got != http.StatusTooManyRequests { + t.Fatalf("energy over budget: %d", got) + } + if got := send(t, s, cfg, inner(fleet).Marshal(reportwire.RequestSize)); got != http.StatusNoContent { + t.Fatalf("fleet beside a spent energy budget: %d", got) + } +} + +// A sender that stops halfway through its body is cut off. +func TestASlowBodyIsCutOff(t *testing.T) { + s, _, _ := newService(t) + old := bodyDeadline + bodyDeadline = 200 * time.Millisecond + t.Cleanup(func() { bodyDeadline = old }) + srv := httptest.NewServer(s) + defer srv.Close() + conn, err := net.Dial("tcp", srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _, _ = fmt.Fprintf(conn, "POST /v1/ohttp HTTP/1.1\r\nHost: x\r\nContent-Type: message/ohttp-req\r\nContent-Length: 1000\r\n\r\nabc") + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil || resp.StatusCode != http.StatusRequestTimeout { + t.Fatalf("slow body: %v %v", resp, err) + } +} + +type publicView struct { + Schema string `json:"schema"` + Meaning string `json:"meaning"` + ObservedAt string `json:"observed_at"` + Minimum int `json:"minimum"` + Days []map[string]any `json:"days"` +} + +// A date is public once it is closed for reports and holds at least five. +func TestAPublicDateIsClosedAndHoldsFiveReports(t *testing.T) { + s, reg, _ := newService(t) + add := func(date string, n int) { + var r energywire.Report + _ = json.Unmarshal([]byte(strings.Replace(good, "2026-10-23", date, 1)), &r) + for range n { + if ok, err := reg.EnergyAdd(r); err != nil || !ok { + t.Fatal(err) + } + } + } + add("2026-10-18", 5) + add("2026-10-19", 4) + // Five reports, one of them with a car: that sum is that car's. + add("2026-10-17", 4) + var car energywire.Report + _ = json.Unmarshal([]byte(strings.Replace(strings.Replace(good, "2026-10-23", "2026-10-17", 1), `"vehicle_charge_kwh":0`, `"vehicle_charge_kwh":20`, 1)), &car) + if ok, err := reg.EnergyAdd(car); err != nil || !ok { + t.Fatal(err) + } + add("2026-10-23", 9) // still open for reports + add("2026-09-23", 9) // past the window + + w := httptest.NewRecorder() + s.Public(w, httptest.NewRequest(http.MethodGet, "/v1/energy/public", nil)) + var v publicView + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatal(err) + } + if v.Schema != "ftw.energy-public/1" || v.Meaning != "daily reports, not homes" || v.Minimum != 5 || v.ObservedAt != "2026-10-24T09:00:00Z" { + t.Fatalf("view %+v", v) + } + if len(v.Days) != 27 || v.Days[0]["date"] != "2026-10-20" || v.Days[26]["date"] != "2026-09-24" { + t.Fatalf("%d days from %v to %v", len(v.Days), v.Days[0]["date"], v.Days[len(v.Days)-1]["date"]) + } + byDate := map[string]map[string]any{} + for _, d := range v.Days { + if len(d) != 2+len(energywire.Fields) { + t.Fatalf("fields %v", d) + } + byDate[d["date"].(string)] = d + } + if d := byDate["2026-10-18"]; d["reports"] != 5.0 || d["pv_generation_kwh"] != 60.0 || d["vehicle_charge_kwh"] != 0.0 { + t.Fatalf("five reports: %v", d) + } + if d := byDate["2026-10-17"]; d["reports"] != 5.0 || d["pv_generation_kwh"] != 60.0 || d["vehicle_charge_kwh"] != nil { + t.Fatalf("one car among five: %v", d) + } + for _, date := range []string{"2026-10-19", "2026-10-20"} { + for k, v := range byDate[date] { + if k != "date" && v != nil { + t.Fatalf("%s shows %s=%v", date, k, v) + } + } + } + if _, open := byDate["2026-10-23"]; open { + t.Fatal("a date still open for reports is public") + } +} + +func TestTheKeyConfigurationIsServed(t *testing.T) { + s, _, cfg := newService(t) + w := httptest.NewRecorder() + s.Keys(w, httptest.NewRequest(http.MethodGet, "/.well-known/ohttp-gateway", nil)) + if w.Header().Get("Content-Type") != ohttp.KeysType || !bytes.Equal(w.Body.Bytes(), ohttp.MarshalKeys(cfg)) { + t.Fatalf("%v %x", w.Header(), w.Body.Bytes()) + } + off, err := New(s.reg, nil) + if err != nil { + t.Fatal(err) + } + for _, h := range []http.HandlerFunc{off.ServeHTTP, off.Keys} { + w := httptest.NewRecorder() + h(w, httptest.NewRequest(http.MethodPost, "/v1/ohttp", nil)) + if w.Code != http.StatusNotImplemented { + t.Fatalf("without a key: %d", w.Code) + } + } +} diff --git a/internal/registry/energy.go b/internal/registry/energy.go new file mode 100644 index 0000000..c1730a9 --- /dev/null +++ b/internal/registry/energy.go @@ -0,0 +1,114 @@ +package registry + +import ( + "database/sql" + "time" + + "github.com/srcfl/ftw/go/protocol/energywire" +) + +// EnergyDays is how long a date's energy totals are kept. +const EnergyDays = 30 + +// EnergyInit creates only daily totals: one row per date, a count, six sums, +// and for each sum how many reports had any of it. No report, address or time +// of arrival goes on disk. +func (r *Registry) EnergyInit() error { + _, err := r.db.Exec(`CREATE TABLE IF NOT EXISTS energy_days ( + date TEXT PRIMARY KEY, + reports INTEGER NOT NULL, + pv_generation_kwh INTEGER NOT NULL, + grid_import_kwh INTEGER NOT NULL, + grid_export_kwh INTEGER NOT NULL, + battery_charge_kwh INTEGER NOT NULL, + battery_discharge_kwh INTEGER NOT NULL, + vehicle_charge_kwh INTEGER NOT NULL, + pv_generation_reports INTEGER NOT NULL, + grid_import_reports INTEGER NOT NULL, + grid_export_reports INTEGER NOT NULL, + battery_charge_reports INTEGER NOT NULL, + battery_discharge_reports INTEGER NOT NULL, + vehicle_charge_reports INTEGER NOT NULL)`) + return err +} + +// EnergyAdd adds one report to its date's totals in one statement. False +// means the date already holds energywire.MaxReports. +func (r *Registry) EnergyAdd(rep energywire.Report) (bool, error) { + return energyAdd(r.db, rep) +} + +// execer is a database or a transaction. +type execer interface { + Exec(query string, args ...any) (sql.Result, error) +} + +func energyAdd(db execer, rep energywire.Report) (bool, error) { + s := rep.Sums() + var had [6]int + for i, v := range s { + if v > 0 { + had[i] = 1 + } + } + res, err := db.Exec(`INSERT INTO energy_days VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(date) DO UPDATE SET reports = reports + 1, + pv_generation_kwh = pv_generation_kwh + excluded.pv_generation_kwh, + grid_import_kwh = grid_import_kwh + excluded.grid_import_kwh, + grid_export_kwh = grid_export_kwh + excluded.grid_export_kwh, + battery_charge_kwh = battery_charge_kwh + excluded.battery_charge_kwh, + battery_discharge_kwh = battery_discharge_kwh + excluded.battery_discharge_kwh, + vehicle_charge_kwh = vehicle_charge_kwh + excluded.vehicle_charge_kwh, + pv_generation_reports = pv_generation_reports + excluded.pv_generation_reports, + grid_import_reports = grid_import_reports + excluded.grid_import_reports, + grid_export_reports = grid_export_reports + excluded.grid_export_reports, + battery_charge_reports = battery_charge_reports + excluded.battery_charge_reports, + battery_discharge_reports = battery_discharge_reports + excluded.battery_discharge_reports, + vehicle_charge_reports = vehicle_charge_reports + excluded.vehicle_charge_reports + WHERE reports < ?`, + rep.Date, s[0], s[1], s[2], s[3], s[4], s[5], had[0], had[1], had[2], had[3], had[4], had[5], energywire.MaxReports) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n == 1, err +} + +// EnergyDay is one date's totals, sums in energywire.Fields order. Had counts, +// for each sum, the reports with any of it: a sum of five reports that only +// one had any of is that one home's. +type EnergyDay struct { + Date string + Reports int64 + Sums [6]int64 + Had [6]int64 +} + +// EnergyTotals reads the dates from first to last, both included. +func (r *Registry) EnergyTotals(first, last string) (map[string]EnergyDay, error) { + rows, err := r.rd.Query(`SELECT date, reports, pv_generation_kwh, grid_import_kwh, grid_export_kwh, + battery_charge_kwh, battery_discharge_kwh, vehicle_charge_kwh, + pv_generation_reports, grid_import_reports, grid_export_reports, + battery_charge_reports, battery_discharge_reports, vehicle_charge_reports + FROM energy_days WHERE date >= ? AND date <= ? LIMIT ?`, first, last, EnergyDays+1) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]EnergyDay{} + for rows.Next() { + var d EnergyDay + if err := rows.Scan(&d.Date, &d.Reports, &d.Sums[0], &d.Sums[1], &d.Sums[2], &d.Sums[3], &d.Sums[4], &d.Sums[5], + &d.Had[0], &d.Had[1], &d.Had[2], &d.Had[3], &d.Had[4], &d.Had[5]); err != nil { + return nil, err + } + out[d.Date] = d + } + return out, rows.Err() +} + +// EnergyPrune drops dates older than EnergyDays, even when no report comes. +func (r *Registry) EnergyPrune(now time.Time) error { + _, err := r.db.Exec(`DELETE FROM energy_days WHERE date < ?`, now.UTC().AddDate(0, 0, -EnergyDays).Format(time.DateOnly)) + return err +} diff --git a/internal/registry/energy_test.go b/internal/registry/energy_test.go new file mode 100644 index 0000000..0a17e3a --- /dev/null +++ b/internal/registry/energy_test.go @@ -0,0 +1,57 @@ +package registry + +import ( + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol/energywire" +) + +// Reports leave one row per date, a count and six sums, and nothing that +// was in any one of them. +func TestEnergyKeepsOnlyDailyTotals(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "registry.db")) + if err != nil { + t.Fatal(err) + } + defer r.Close() + if err := r.EnergyInit(); err != nil { + t.Fatal(err) + } + rep := energywire.Report{Schema: energywire.Schema, Date: "2026-10-23", PVGeneration: 7, GridImport: 1} + for i := range 10 { + rep.Date = []string{"2026-10-22", "2026-10-23"}[i%2] + if ok, err := r.EnergyAdd(rep); err != nil || !ok { + t.Fatal(err) + } + } + var rows, columns int + if err := r.db.QueryRow(`SELECT COUNT(*) FROM energy_days`).Scan(&rows); err != nil || rows != 2 { + t.Fatalf("rows %d %v", rows, err) + } + if err := r.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('energy_days')`).Scan(&columns); err != nil || columns != 2+2*len(energywire.Fields) { + t.Fatalf("columns %d %v", columns, err) + } + totals, err := r.EnergyTotals("2026-10-22", "2026-10-23") + if d := totals["2026-10-23"]; err != nil || d.Reports != 5 || d.Sums[0] != 35 || d.Sums[1] != 5 || d.Had != [6]int64{5, 5, 0, 0, 0, 0} { + t.Fatalf("%+v %v", d, err) + } + + // A full date takes no more. + if _, err := r.db.Exec(`UPDATE energy_days SET reports = ? WHERE date = '2026-10-23'`, energywire.MaxReports); err != nil { + t.Fatal(err) + } + if ok, err := r.EnergyAdd(rep); err != nil || ok { + t.Fatalf("a full date took a report: %v", err) + } + + // Dates expire by age, even when no report arrives. + if err := r.EnergyPrune(time.Date(2026, 11, 22, 1, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + totals, _ = r.EnergyTotals("2026-10-01", "2026-10-31") + if _, ok := totals["2026-10-22"]; ok || len(totals) != 1 { + t.Fatalf("after pruning %v", totals) + } +} diff --git a/internal/registry/fleet.go b/internal/registry/fleet.go index 97b6c67..8b64cec 100644 --- a/internal/registry/fleet.go +++ b/internal/registry/fleet.go @@ -22,10 +22,17 @@ func (r *Registry) FleetPut(report fleetwire.Report, now time.Time) (bool, error return false, err } defer tx.Rollback() + if ok, err := fleetPutTx(tx, report, now); err != nil || !ok { + return false, err + } + return true, tx.Commit() +} + +func fleetPutTx(tx *sql.Tx, report fleetwire.Report, now time.Time) (bool, error) { date := now.UTC().Format(time.DateOnly) day := fleetwire.NewDay(date) var old []byte - err = tx.QueryRow(`SELECT totals FROM fleet_days WHERE date = ?`, date).Scan(&old) + err := tx.QueryRow(`SELECT totals FROM fleet_days WHERE date = ?`, date).Scan(&old) if err != nil && !errors.Is(err, sql.ErrNoRows) { return false, err } @@ -47,7 +54,7 @@ func (r *Registry) FleetPut(report fleetwire.Report, now time.Time) (bool, error if _, err := tx.Exec(`DELETE FROM fleet_days WHERE date < ? OR date NOT IN (SELECT date FROM fleet_days ORDER BY date DESC LIMIT ?)`, now.UTC().AddDate(0, 0, 1-fleetwire.MaxDays).Format(time.DateOnly), fleetwire.MaxDays); err != nil { return false, err } - return true, tx.Commit() + return true, nil } func (r *Registry) FleetView(now time.Time) (fleetwire.View, error) { diff --git a/internal/registry/reports.go b/internal/registry/reports.go new file mode 100644 index 0000000..1ce7ed6 --- /dev/null +++ b/internal/registry/reports.go @@ -0,0 +1,79 @@ +package registry + +import ( + "crypto/sha256" + "database/sql" + "errors" + "time" + + "github.com/srcfl/ftw/go/protocol/energywire" + "github.com/srcfl/ftw/go/protocol/fleetwire" +) + +// ReportIDDays is how long a report's id is kept: the day it was sent and +// the next, when the gateway still takes it, and one more. +const ReportIDDays = 3 + +// ReportsInit creates the ids of the reports the gateway counted: a hash of +// each report's random id under the day the box sent it, so a replay or a +// retry counts once (RFC 9458, section 6.5). The table has no time, row id +// or arrival order: it is keyed by the hash. +func (r *Registry) ReportsInit() error { + _, err := r.db.Exec(`CREATE TABLE IF NOT EXISTS report_ids ( + day TEXT NOT NULL, + id BLOB NOT NULL, + PRIMARY KEY (day, id)) WITHOUT ROWID`) + return err +} + +// FleetPutOnce adds a fleet report to today's counters unless its id was +// counted already. +func (r *Registry) FleetPutOnce(day string, id []byte, report fleetwire.Report, now time.Time) (added, repeat bool, err error) { + return r.once(day, id, func(tx *sql.Tx) (bool, error) { return fleetPutTx(tx, report, now) }) +} + +// EnergyAddOnce adds an energy report to its date's totals unless its id was +// counted already. +func (r *Registry) EnergyAddOnce(day string, id []byte, rep energywire.Report) (added, repeat bool, err error) { + return r.once(day, id, func(tx *sql.Tx) (bool, error) { return energyAdd(tx, rep) }) +} + +// once records the id and runs add in one transaction. A repeat runs nothing. +// When add refuses (a full day), the id is not kept either. +func (r *Registry) once(day string, id []byte, add func(*sql.Tx) (bool, error)) (added, repeat bool, err error) { + sum := sha256.Sum256(id) + tx, err := r.db.Begin() + if err != nil { + return false, false, err + } + defer tx.Rollback() + res, err := tx.Exec(`INSERT INTO report_ids(day, id) VALUES (?, ?) ON CONFLICT DO NOTHING`, day, sum[:16]) + if err != nil { + return false, false, err + } + if n, err := res.RowsAffected(); err != nil || n == 0 { + return false, err == nil, err + } + if added, err = add(tx); err != nil || !added { + return false, false, err + } + return true, false, tx.Commit() +} + +// ReportSeen reports whether a report's id was counted already, without +// recording it, so a repeat can be answered before any budget is spent. +func (r *Registry) ReportSeen(day string, id []byte) (bool, error) { + sum := sha256.Sum256(id) + var one int + err := r.rd.QueryRow(`SELECT 1 FROM report_ids WHERE day = ? AND id = ?`, day, sum[:16]).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +// ReportsPrune drops ids older than ReportIDDays. +func (r *Registry) ReportsPrune(now time.Time) error { + _, err := r.db.Exec(`DELETE FROM report_ids WHERE day < ?`, now.UTC().AddDate(0, 0, 1-ReportIDDays).Format(time.DateOnly)) + return err +} diff --git a/internal/server/reports_e2e_test.go b/internal/server/reports_e2e_test.go new file mode 100644 index 0000000..20ed7d5 --- /dev/null +++ b/internal/server/reports_e2e_test.go @@ -0,0 +1,121 @@ +package server + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "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" +) + +func testGateway(t *testing.T) *ohttp.Gateway { + t.Helper() + k, err := ecdh.X25519().GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + g, err := ohttp.NewGateway(ohttp.Key{ID: 3, Private: k}) + if err != nil { + t.Fatal(err) + } + return g +} + +// Box to relay to gateway to totals, for both reports. The box half is what +// Core sends: the key configuration from /.well-known/ohttp-gateway, each +// report alone in the one padded inner request. The relay forwards only the +// body and its type, never sees a report, and sees one length. ftwcloudd adds +// each to its own day totals and answers a sealed 204. +func TestBothReportsReachTheirTotalsThroughARelay(t *testing.T) { + s, _ := newServer(t) + cloud := httptest.NewServer(s.Handler()) + defer cloud.Close() + + var seen [][]byte + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seen = append(seen, body) + resp, err := http.Post(cloud.URL+"/v1/ohttp", 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) + })) + defer relay.Close() + + resp, err := http.Get(cloud.URL + "/.well-known/ohttp-gateway") + if err != nil { + t.Fatal(err) + } + keys, _ := io.ReadAll(resp.Body) + resp.Body.Close() + configs, err := ohttp.ParseKeys(keys) + if err != nil || len(configs) != 1 || resp.Header.Get("Content-Type") != ohttp.KeysType { + t.Fatalf("key configuration %x %v", keys, err) + } + + yesterday := time.Now().UTC().AddDate(0, 0, -1).Format(time.DateOnly) + report := energywire.Report{Schema: energywire.Schema, Date: yesterday, PVGeneration: 12, GridImport: 30, GridExport: 4, BatteryCharge: 9, BatteryDischarge: 8} + energyBody, _ := json.Marshal(report) + fleetBody, _ := json.Marshal(fleetwire.Report{Schema: fleetwire.Schema, Version: "v0.146.0", Channel: "stable", Drivers: []string{"sungrow"}, Battery: "5-15", Zone: "SE4", Age: "1-6m"}) + // Three energy reports and two fleet reports, each alone. + for i, body := range [][]byte{energyBody, fleetBody, energyBody, fleetBody, energyBody} { + id := make([]byte, reportwire.IDBytes) + _, _ = rand.Read(id) + inner, ok := reportwire.Request(reportwire.Report{ID: id, Day: time.Now().UTC().Format(time.DateOnly), Body: body}) + if !ok { + t.Fatal("report does not fit") + } + sealed, reader, err := ohttp.Encapsulate(configs[0], inner) + if err != nil { + t.Fatal(err) + } + resp, err := http.Post(relay.URL, ohttp.RequestType, bytes.NewReader(sealed)) + if err != nil { + t.Fatal(err) + } + answer, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != ohttp.ResponseType { + t.Fatalf("relay got %d %v", resp.StatusCode, resp.Header) + } + msg, err := reader.Open(answer) + if err != nil { + t.Fatal(err) + } + if got, err := ohttp.ParseResponse(msg); err != nil || got.Status != http.StatusNoContent || len(msg) != reportwire.ResponseSize { + t.Fatalf("report %d: %+v %v", i, got, err) + } + if bytes.Contains(seen[i], []byte("kwh")) || bytes.Contains(seen[i], []byte(yesterday)) || bytes.Contains(seen[i], []byte("sungrow")) { + t.Fatal("the relay saw the report") + } + } + totals, err := s.Registry.EnergyTotals(yesterday, yesterday) + day := totals[yesterday] + if err != nil || day.Reports != 3 || day.Sums != [6]int64{36, 90, 12, 27, 24, 0} { + t.Fatalf("totals %+v %v", day, err) + } + for _, b := range seen { + if len(b) != len(seen[0]) { + t.Fatal("reports differ in length on the relay") + } + } + // The fleet reports land where /v1/fleet/public reads them. + view, err := s.Registry.FleetView(time.Now()) + if err != nil || len(view.Days) != 1 || view.Days[0].Reports != 2 || view.Days[0].Drivers["sungrow"] != 2 { + t.Fatalf("fleet %+v %v", view, err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7b5fcb8..aa50ba8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,6 +5,9 @@ // /v1/app a phone's Noise session to the service (package appsession) // /v1/app/once a watch's one-shot read, Noise inside one POST (package appsession) // /v1/now a widget's token read (package token) +// /v1/ohttp the Oblivious HTTP gateway for the anonymous daily reports (package gateway) +// /.well-known/ohttp-gateway that gateway's key configuration +// /v1/fleet, /v1/fleet/public, /v1/energy/public anonymous daily totals // /source the source of the running service, as AGPL-3.0 asks // /healthz liveness, and nothing about any home // /oauth/*, /.well-known/oauth-* the authorization server for assistants (package oauth) @@ -26,6 +29,7 @@ import ( "time" "github.com/srcfl/ftw-cloud/internal/appsession" + "github.com/srcfl/ftw-cloud/internal/gateway" "github.com/srcfl/ftw-cloud/internal/oauth" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/store" @@ -37,7 +41,8 @@ import ( const SourceArchive = "/usr/share/ftw-cloud/source.tar.gz" // Routes are every path the service answers. -var Routes = append([]string{"/v1/box", "/v1/app", "/v1/app/once", "/v1/now", "/v1/fleet", "/v1/fleet/public", "/v1/stats", "/source", "/healthz", "/mcp"}, oauth.Routes...) +var Routes = append([]string{"/v1/box", "/v1/app", "/v1/app/once", "/v1/now", "/v1/fleet", "/v1/fleet/public", + "/v1/ohttp", "/.well-known/ohttp-gateway", "/v1/energy/public", "/v1/stats", "/source", "/healthz", "/mcp"}, oauth.Routes...) // devRoutes adds a local inspection route in binaries built with // -tags ftwclouddev. It is nil in every other build. @@ -58,6 +63,7 @@ type Server struct { // they call. Nil serves none. OAuth *oauth.Server Fleet http.Handler + Gateway *gateway.Service Statistics http.Handler MCP http.Handler Log *slog.Logger @@ -100,6 +106,13 @@ func (s *Server) Handler() http.Handler { } else { mux.HandleFunc("/v1/fleet", notYet) } + if s.Gateway != nil { + mux.Handle("/v1/ohttp", s.Gateway) + mux.HandleFunc("/.well-known/ohttp-gateway", s.Gateway.Keys) + mux.HandleFunc("/v1/energy/public", s.Gateway.Public) + } else { + mux.HandleFunc("/v1/ohttp", notYet) + } if s.Statistics != nil { mux.Handle("/v1/stats", s.Statistics) } else { @@ -197,6 +210,11 @@ func (s *Server) Upkeep(ctx context.Context, every time.Duration) { s.Log.Warn("fleet upkeep failed") } } + if s.Gateway != nil { + if err := s.Gateway.Prune(time.Now()); err != nil { + s.Log.Warn("energy upkeep failed") + } + } if f, ok := s.Statistics.(interface{ Prune(time.Time) error }); ok { if err := f.Prune(time.Now()); err != nil { s.Log.Warn("statistics upkeep failed") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c3ec50c..b3a5a43 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -15,6 +15,7 @@ import ( "github.com/srcfl/ftw-cloud/internal/appsession" "github.com/srcfl/ftw-cloud/internal/fleet" + "github.com/srcfl/ftw-cloud/internal/gateway" "github.com/srcfl/ftw-cloud/internal/registry" "github.com/srcfl/ftw-cloud/internal/statistics" "github.com/srcfl/ftw-cloud/internal/store" @@ -47,6 +48,9 @@ func newServer(t *testing.T) (*Server, string) { if err != nil { t.Fatal(err) } + if s.Gateway, err = gateway.New(reg, testGateway(t)); err != nil { + t.Fatal(err) + } up.Statistics = true s.Statistics.(*statistics.Service).Enabled = true // A home with readings in it, held the way a box's uplink holds them.