Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ maintains it.
the service routes by `dev` and `sid` and never reads.
- **Live readings stay in memory.** The versioned project statistics
scope covers rounded closed-hour energy and configured capacities on
disk for 13 months. Only group cells pass `/v1/stats`; no staff route reads
disk for 48 hours. Only group cells pass `/v1/stats`; no staff route reads
a home. Withdrawal and home purge delete its unpublished statistics.
Memory holds live readings, and only while the roster's
grant serves a copy; SQLite holds only the home id, the box key, the signed
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ The sender refuses redirects, proxies and local destinations.
## Project usage statistics

`GET /v1/stats` publishes only group cells under
`ftw-project-stats-2026-10-v2`. Collection stays off until the signed C8 DPIA
`ftw-project-stats-2026-10-v3`. Collection stays off until the signed C8 DPIA
and the legal basis for both flows have been reviewed. Empty
`FTW_CLOUD_STATISTICS_POLICY` keeps it off; an exact approved policy is needed
to advertise collection. While paused, `/v1/stats` returns 503 with
Expand All @@ -393,7 +393,7 @@ The unsigned [review draft](privacy/project-statistics-dpia.md) records the
open decisions. Clear terms alone do not establish a lawful basis.

Statistics are included for every FTW Cloud user under
`ftw-cloud-2026-10-v2`. Owners accept the current scope through
`ftw-cloud-2026-10-v3`. Owners accept the current scope through
`POST /api/cloud/grant`. There is no separate statistics opt-out within Cloud;
`DELETE /api/cloud` ends Cloud and requests deletion. Old terms do not accept
the new scope. Deploy compatible Core and clients, show the new terms, and
Expand All @@ -408,7 +408,7 @@ no phone keys, pairing codes, Noise key, live cache or remote access.
`DELETE /api/cloud/stats` stops this local contribution. A current Cloud grant
refuses that separate stop with 409. Never answer for another household.

Cloud stores the approved hourly fields for 13 months and deletes unpublished
Cloud stores the approved hourly fields for 48 hours and deletes unpublished
household rows on withdrawal or purge. Only closed hours starting after the
new choice contribute; no earlier ledger history is uploaded. Group values
need 20 homes and no home above 30 percent of a sum. The stats Worker receives
Expand Down
24 changes: 15 additions & 9 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Running FTW Cloud

FTW Cloud runs on one `t4g.small` in AWS eu-north-1 (Stockholm) and answers
at `https://cloud.ftw.energy`. No box sends to it yet: release builds of Core
name no service, and a home can turn FTW Cloud on only from a Core built with
the uplink (srcfl/ftw#1595), after its owner decides to.
at `https://cloud.ftw.energy`. Current Core and native clients can use it after
the owner enables Cloud. Local control stays on the box. Hourly project
statistics remain off until the C8 review and policy approval are complete.

## The host

Expand All @@ -25,7 +25,7 @@ Every AWS resource carries the tag `Project=ftw-cloud`.

## Shape

- One instance, one container (`compose.yml`), no standby. A rebuilt host
- One instance, `ftwcloudd` and the separate keys service (`compose.yml`), no standby. A rebuilt host
starts empty and boxes fill it again when they reconnect.
- `ftwcloudd` ends TLS 1.3 itself and gets its certificate from Let's Encrypt
by TLS-ALPN-01 on 443, so port 80 stays shut. **No TLS-terminating proxy in
Expand All @@ -43,7 +43,9 @@ Every AWS resource carries the tag `Project=ftw-cloud`.
supplies `compose.yml`.
- The Docker volume `ftw-cloud_ftw-cloud-state` holds the SQLite registry
(home ids, box keys, signed rosters, tombstones) and the ACME cache, on the
encrypted root disk. No readings. No backup.
encrypted root disk. With the reviewed statistics policy enabled, it also
holds rounded closed-hour reports for 48 hours. Live readings stay in
memory. No statistics backup.

## The service key

Expand All @@ -70,7 +72,8 @@ secret the same way.
- Swap off (`swapoff -a`, no swap in fstab) and core dumps off: the kernel
pipes cores to `/bin/false`, systemd-coredump stores nothing, `compose.yml`
sets `ulimits: core: 0`, and `ftwcloudd` marks itself not dumpable.
Household readings exist only in this process's memory.
Live readings stay in process memory. The separate project-statistics
scope stores only its approved closed-hour fields on disk for 48 hours.
- Nothing comes in but 443, TCP and UDP. There is no SSH and no key pair;
administration is through SSM. Session logs to a separate account are not set up yet.
- Outbound is open. The host now needs only GitHub and `ghcr.io` (the image
Expand Down Expand Up @@ -373,6 +376,9 @@ Cloud data; the images on `ghcr.io` hold only code.
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
security group ([http3.md](http3.md)). Widget tokens need no
secret: the registry holds their hashes. Statistics (C7–C8) add secrets,
egress and storage when they land. The erase test, canary homes
and the external heartbeat that pages Sourceful belong with the first homes.
secret: the registry holds their hashes. Statistics (C8) have their own
[review](../privacy/project-statistics-dpia.md) and
[rights and retention procedure](../privacy/statistics-operations.md). The
policy flag stays empty until that review is signed off. Signed deletion,
offline rights handling and startup retention have tests. Canary homes and
the external heartbeat that pages Sourceful still need production evidence.
3 changes: 2 additions & 1 deletion deploy/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ services:
# "Deploys").
stop_grace_period: 15s
volumes:
# The registry and certificates. No readings are ever written here.
# Registry, certificates and, only after C8 approval, closed-hour
# project statistics with 48-hour retention. Live readings stay in memory.
- ftw-cloud-state:/state
# Apple's push key, read from Parameter Store into tmpfs at boot by
# deploy/apns.sh. Empty means no alerts, and the service runs.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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-20261017145623-2b1cc2563972
github.com/srcfl/ftw/go/protocol v0.0.0-20261017163816-75a8b2c910ad
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.org/x/sys v0.48.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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-20261017145623-2b1cc2563972 h1:+YZbUeomGm9bDdpVICXMJS8AbgaTaeWrRA+QZjcuNAc=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017145623-2b1cc2563972/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017163816-75a8b2c910ad h1:DFoeYtGWX2iVb6QdZMY4rv7HRrhUG649sKs2Yjd6X0s=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017163816-75a8b2c910ad/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=
Expand Down
98 changes: 86 additions & 12 deletions internal/registry/statistics.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,41 @@ import (
// Statistics tables contain only versioned, owner-approved closed-hour values.
// They have no staff read route, and leave with the home's purge.
func (r *Registry) StatisticsInit() error {
_, err := r.db.Exec(`CREATE TABLE IF NOT EXISTS statistics_grants (
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`CREATE TABLE IF NOT EXISTS statistics_grants (
home TEXT PRIMARY KEY REFERENCES homes(home) ON DELETE CASCADE,
stats_id BLOB NOT NULL, since_ms INTEGER NOT NULL);
stats_id BLOB NOT NULL, since_ms INTEGER NOT NULL, policy TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS statistics_hours (
home TEXT NOT NULL REFERENCES homes(home) ON DELETE CASCADE,
hour_ms INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY(home,hour_ms));
CREATE TABLE IF NOT EXISTS statistics_restrictions (
home TEXT PRIMARY KEY REFERENCES homes(home) ON DELETE CASCADE);
Comment thread
frahlg marked this conversation as resolved.
CREATE INDEX IF NOT EXISTS statistics_hours_time ON statistics_hours(hour_ms);`)
return err
if err != nil {
return err
}
var column int
if err := tx.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('statistics_grants') WHERE name='policy'`).Scan(&column); err != nil {
return err
}
if column == 0 {
if _, err := tx.Exec(`ALTER TABLE statistics_grants ADD COLUMN policy TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
// A stored grant from an older binary proves no acceptance of this policy.
if _, err := tx.Exec(`DELETE FROM statistics_hours WHERE NOT EXISTS
(SELECT 1 FROM statistics_grants g WHERE g.home=statistics_hours.home AND g.policy=?)`, cloudwire.StatsPolicy); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM statistics_grants WHERE policy<>?`, cloudwire.StatsPolicy); err != nil {
return err
}
return tx.Commit()
}

// StatisticsGrant clears old consent rows before a changed grant may send.
Expand All @@ -33,15 +60,25 @@ func (r *Registry) StatisticsGrant(home string, g cloudwire.Grant) error {
}
defer tx.Rollback()
var old []byte
err = tx.QueryRow(`SELECT stats_id FROM statistics_grants WHERE home=?`, home).Scan(&old)
var oldPolicy string
err = tx.QueryRow(`SELECT stats_id,policy FROM statistics_grants WHERE home=?`, home).Scan(&old, &oldPolicy)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
terms := (g.TermsPolicy == cloudwire.CloudTermsPolicy && slices.Contains(g.Purposes, protocol.CloudPurposeServe)) ||
(g.TermsPolicy == cloudwire.StatsPolicy && len(g.Scopes) == 0 && len(g.Purposes) == 1 && g.Purposes[0] == protocol.CloudPurposeStats)
on := terms && g.Controller == cloudwire.ControllerSourceful && g.StatsPolicy == cloudwire.StatsPolicy &&
len(g.StatsID) == 16 && g.StatsSinceMs > 0 && slices.Contains(g.Purposes, protocol.CloudPurposeStats)
if !on || !bytes.Equal(old, g.StatsID) {
if on {
var restricted int
if err := tx.QueryRow(`SELECT COUNT(*) FROM statistics_restrictions WHERE home=?`, home).Scan(&restricted); err != nil {
return err
}
if restricted != 0 {
return errors.New("statistics: subject request restricts this home")
}
}
if !on || !bytes.Equal(old, g.StatsID) || oldPolicy != cloudwire.StatsPolicy {
if _, err := tx.Exec(`DELETE FROM statistics_hours WHERE home=?`, home); err != nil {
return err
}
Expand All @@ -50,8 +87,8 @@ func (r *Registry) StatisticsGrant(home string, g cloudwire.Grant) error {
}
}
if on {
if _, err := tx.Exec(`INSERT INTO statistics_grants(home,stats_id,since_ms) VALUES(?,?,?)
ON CONFLICT(home) DO UPDATE SET stats_id=excluded.stats_id,since_ms=excluded.since_ms`, home, g.StatsID, g.StatsSinceMs); err != nil {
if _, err := tx.Exec(`INSERT INTO statistics_grants(home,stats_id,since_ms,policy) VALUES(?,?,?,?)
ON CONFLICT(home) DO UPDATE SET stats_id=excluded.stats_id,since_ms=excluded.since_ms,policy=excluded.policy`, home, g.StatsID, g.StatsSinceMs, g.StatsPolicy); err != nil {
return err
}
}
Expand All @@ -69,14 +106,51 @@ func (r *Registry) StatisticsPut(home string, report cloudwire.Stats, now time.T
// Consent and the write are one SQL statement: a withdrawn grant cannot
// race a pending report back into storage. Reconnects replace one hour.
_, err = r.db.Exec(`INSERT INTO statistics_hours(home,hour_ms,metrics)
SELECT home,?,? FROM statistics_grants WHERE home=? AND stats_id=? AND since_ms<=?
ON CONFLICT(home,hour_ms) DO UPDATE SET metrics=excluded.metrics`, report.HourMS, data, home, report.StatsID, report.HourMS)
SELECT home,?,? FROM statistics_grants WHERE home=? AND stats_id=? AND since_ms<=? AND policy=?
AND NOT EXISTS (SELECT 1 FROM statistics_restrictions x WHERE x.home=statistics_grants.home)
ON CONFLICT(home,hour_ms) DO UPDATE SET metrics=excluded.metrics`, report.HourMS, data, home, report.StatsID, report.HourMS, cloudwire.StatsPolicy)
return err
}

// StatisticsRestrictions is read once before restoring the in-memory homes.
func (r *Registry) StatisticsRestrictions() (map[string]bool, error) {
rows, err := r.rd.Query(`SELECT home FROM statistics_restrictions`)
if err != nil {
return nil, err
}
defer rows.Close()
homes := map[string]bool{}
for rows.Next() {
var home string
if err := rows.Scan(&home); err != nil {
return nil, err
}
homes[home] = true
}
return homes, rows.Err()
}

// StatisticsRestricted is an operator-imposed rights hold, never an owner
// setting. Only the offline rights procedure writes it with Cloud stopped.
func (r *Registry) StatisticsRestricted(home string) (bool, error) {
var n int
err := r.rd.QueryRow(`SELECT COUNT(*) FROM statistics_restrictions WHERE home=?`, home).Scan(&n)
return n != 0, err
}

func (r *Registry) StatisticsPrune(now time.Time) error {
_, err := r.db.Exec(`DELETE FROM statistics_hours WHERE hour_ms<?`, now.UTC().AddDate(0, -13, 0).UnixMilli())
return err
_, err := r.db.Exec(`DELETE FROM statistics_hours WHERE hour_ms<?`, now.UnixMilli()-cloudwire.StatsRetentionMS)
Comment thread
frahlg marked this conversation as resolved.
if err != nil {
return err
}
var busy, frames, done int
if err := r.db.QueryRow(`PRAGMA wal_checkpoint(TRUNCATE)`).Scan(&busy, &frames, &done); err != nil {
return err
}
if busy != 0 {
return errors.New("statistics: WAL checkpoint busy")
}
return nil
}

type StatisticsCell struct {
Expand Down Expand Up @@ -123,7 +197,7 @@ func (r *Registry) StatisticsView(now time.Time) (StatisticsView, error) {
v := StatisticsView{Schema: "ftw.project-usage/1", ObservedAt: now.UTC().Format(time.RFC3339),
WindowStart: start.Format(time.RFC3339), WindowEnd: end.Format(time.RFC3339), Minimum: cloudwire.StatsMinimumHomes, Metrics: map[string]StatisticsCell{}}
rows, err := r.rd.Query(`SELECT h.home,h.hour_ms,h.metrics FROM statistics_hours h
JOIN statistics_grants g ON g.home=h.home WHERE h.hour_ms>=? AND h.hour_ms<? ORDER BY h.hour_ms DESC`, start.UnixMilli(), end.UnixMilli())
JOIN statistics_grants g ON g.home=h.home WHERE g.policy=? AND h.hour_ms>=? AND h.hour_ms<? ORDER BY h.hour_ms DESC`, cloudwire.StatsPolicy, start.UnixMilli(), end.UnixMilli())
if err != nil {
return v, err
}
Expand Down
124 changes: 124 additions & 0 deletions internal/registry/statistics_rights_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package registry

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)

// The operator script reads a real registry made by Go, not a second schema
// invented by its own test. No Core or household is involved.
func TestStatisticsSubjectExportIsPrivateScopedAndOffline(t *testing.T) {
r, now, g := statsFixture(t)
home, other := strings.Repeat("a", 26), strings.Repeat("b", 26)
report := statsHome(t, r, now, g, home, 1000)
statsHome(t, r, now, g, other, 2000)
var path string
if err := r.db.QueryRow("SELECT file FROM pragma_database_list WHERE name='main'").Scan(&path); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
request := filepath.Join(dir, "request.json")
output := filepath.Join(dir, "export.json")
data, _ := json.Marshal(map[string]any{"case_id": "0af8aa77-b089-4250-865a-431384d0d39c", "home": home,
"identity_checked": true, "verified_by": "test-operator", "verified_at": time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(request, data, 0600); err != nil {
t.Fatal(err)
}
run := func() ([]byte, error) {
return exec.Command("python3", "../../scripts/statistics-subject-request.py", "--database", path, "--request", request, "--output", output).CombinedOutput()
}
if out, err := run(); err == nil {
t.Fatalf("export ran with the service's registry open: %s", out)
}
if out, err := exec.Command("python3", "../../scripts/statistics-subject-request.py", "--database", path, "--request", request, "--erase").CombinedOutput(); err == nil {
t.Fatalf("erasure ran with Cloud open: %s", out)
}
if _, err := os.Stat(output); !os.IsNotExist(err) {
t.Fatal("refused export left a file")
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if out, err := run(); err != nil {
t.Fatalf("offline export: %v %s", err, out)
}
raw, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var exported struct {
Home string `json:"home"`
Hours []struct {
Metrics map[string]int64 `json:"metrics"`
} `json:"hours"`
}
if err := json.Unmarshal(raw, &exported); err != nil || exported.Home != home || len(exported.Hours) != 1 || exported.Hours[0].Metrics["flexible_power_w"] != 1000 || strings.Contains(string(raw), other) {
t.Fatalf("export crossed a home boundary: %v", err)
}
info, _ := os.Stat(output)
if info.Mode().Perm() != 0600 {
t.Fatal("export is not private")
}
if _, err := run(); err == nil {
t.Fatal("export overwrote an existing file")
}
if err := os.Chmod(request, 0644); err != nil {
t.Fatal(err)
}
if _, err := run(); err == nil {
t.Fatal("public request accepted")
}
if err := os.Chmod(request, 0600); err != nil {
t.Fatal(err)
}
for _, invalid := range [][]byte{
bytes.Replace(data, []byte(`"identity_checked":true`), []byte(`"identity_checked":false`), 1),
bytes.Replace(data, []byte(time.Now().UTC().Format("2006-01-02")), []byte("2020-01-01"), 1),
bytes.Replace(data, []byte(`"identity_checked":true`), []byte(`"identity_checked":false,"identity_checked":true`), 1),
bytes.Replace(data, []byte(home), []byte(`' OR 1=1 --`), 1),
} {
if err := os.WriteFile(request, invalid, 0600); err != nil {
t.Fatal(err)
}
if _, err := exec.Command("python3", "../../scripts/statistics-subject-request.py", "--database", path, "--request", request, "--erase").CombinedOutput(); err == nil {
t.Fatal("unverified or malformed erasure request accepted")
}
}
if err := os.WriteFile(request, data, 0600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("python3", "../../scripts/statistics-subject-request.py", "--database", path, "--request", request, "--erase").CombinedOutput(); err != nil {
t.Fatalf("offline erasure: %v %s", err, out)
}
r, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer r.Close()
if held, err := r.StatisticsRestricted(home); err != nil || !held {
t.Fatal("erasure did not persist a rights hold", err)
}
if err := r.StatisticsGrant(home, g); err == nil {
t.Fatal("old box restarted restricted collection")
}
g.StatsID = bytes.Repeat([]byte{9}, 16)
if err := r.StatisticsGrant(home, g); err == nil {
t.Fatal("new policy bypassed the unresolved rights request")
}
if err := r.StatisticsPut(home, report, now); err != nil {
t.Fatal(err)
}
var n int
if err := r.rd.QueryRow(`SELECT COUNT(*) FROM statistics_hours WHERE home=?`, home).Scan(&n); err != nil || n != 0 {
t.Fatal("erased hours reappeared", n, err)
}
if err := r.rd.QueryRow(`SELECT COUNT(*) FROM statistics_hours WHERE home=?`, other).Scan(&n); err != nil || n != 1 {
t.Fatal("another home's hours changed", n, err)
}
}
Loading
Loading