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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ maintains it.
never from a body field. Every later box message but `tun` must verify
with that key. `tun` is unsigned: its `d` is a phone's ciphertext, which
the service routes by `dev` and `sid` and never reads.
- **No readings on disk.** Memory holds readings, and only while the roster's
- **Live readings stay in memory.** The separate, versioned project statistics
consent permits rounded closed-hour energy and configured capacities on
disk for 13 months. 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
roster, owners' suspensions, tombstones, APNs registrations, the hashes of
extension tokens when each away box's alert is due, browser notice sent markers and
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,46 @@ events scope and suspension before sending. A readable-copy grant is not
needed. The roster holds the delivery endpoints and ciphertext; a separate
per-home row holds only the current outage's sent ids. Purge removes both.
The sender refuses redirects, proxies and local destinations.

## Project usage statistics

`GET /v1/stats` publishes only group cells under
`ftw-project-stats-2026-10-v1`. Boxes send signed `stats` hours only after the
owner accepts that policy and the service advertises it in signed `hello_ok`.
The service starts with collection off. After the signed C8 DPIA review,
operators can set `FTW_CLOUD_STATISTICS_POLICY` to that exact policy in the
host's compose environment; empty keeps it off. An unapproved policy makes
`/v1/stats` return 503 with `FTW-Statistics-State: disabled`. Withdrawal,
purge and retention remain active while collection is off. The review draft
is in [privacy/project-statistics-dpia.md](privacy/project-statistics-dpia.md). The previous statistics policy sends no new
fields. Reporting installations in the last 24 closed hours are not people
or total FTW installs. Power and capacity are configured; energy sums
recorded hours, including partial hours. Missing data is unknown.

Cloud stores consented hourly values for 13 months, upserts reconnects, and
deletes household rows on a signed withdrawal or purge. Cells need 20 homes
and no home above 30 percent of the sum. The stats Worker polls this public
view; it receives no home id or per-home value. One instance supplies its
local shard only; public multi-shard totals need an explicit safe collector.

Owner acceptance uses the existing box API. On the box's loopback interface,
read `GET /api/cloud/stats-policy`, accept its terms, then
`POST /api/cloud/stats` with `{"policy":"ftw-project-stats-2026-10-v1"}`.
The readable Cloud grant must already be on. `DELETE /api/cloud/stats`
withdraws this choice. Do not enable it for someone else or reuse an older
consent. Native apps keep the old scope until their consent screen adopts
this policy. The first contributing hour starts after consent; the sender
does not upload earlier ledger history.

Enabling this collection scope needs the signed C8 DPIA and the updated
consent text described by Core's ADR 0008. A deployment with collection off
does not claim that sign-off or change an owner's saved choice. The paired
native consent change requires a fresh choice for this policy.


The separate anonymous daily-report feed is `GET /v1/fleet/public`. It exposes
only daily counts and dimension labels with at least 10 reports, for at most
90 days. It contains imported relay history and new reports received by Cloud;
consumers replace the old archive rather than adding both. It needs no hourly
energy policy. Averaging seven complete UTC days gives an estimate of active
reporting installations, never people or all installations ever made.
14 changes: 13 additions & 1 deletion cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import (
"github.com/srcfl/ftw-cloud/internal/registry"
"github.com/srcfl/ftw-cloud/internal/server"
"github.com/srcfl/ftw-cloud/internal/shard"
"github.com/srcfl/ftw-cloud/internal/statistics"
"github.com/srcfl/ftw-cloud/internal/store"
"github.com/srcfl/ftw-cloud/internal/token"
"github.com/srcfl/ftw-cloud/internal/uplink"
Expand Down Expand Up @@ -98,6 +99,7 @@ func serve(args []string) error {
devCapture := fs.String("dev-apns-capture", "", "write each alert to this directory instead of sending it to Apple (development builds only)")
keysSocket := fs.String("keys-socket", "/run/ftw-cloud-keys/keys.sock", "ftwkeysd's socket; assistants can connect only while it answers")
shardsPath := fs.String("shards", "", "shard directory, JSON; without it this instance holds every home")
statsPolicy := fs.String("statistics-policy", "", "approved statistics policy; empty keeps hourly collection off pending C8 sign-off")
metricsAddr := fs.String("metrics-listen", defaultMetricsAddr, "loopback address for /metrics; empty turns it off")
restartRate := fs.Int("restart-rate", defaultRestartRate, "sockets a second the next instance can take after a restart; sets the spread of the redial")
restartMin := fs.Duration("restart-spread-min", 15*time.Second, "the shortest spread: longer than the port stays closed while the next container starts")
Expand All @@ -120,6 +122,9 @@ func serve(args []string) error {
} else if err != nil {
return err
}
if *statsPolicy != "" && *statsPolicy != cloudwire.StatsPolicy {
return fmt.Errorf("unknown statistics policy")
}
shards := shard.Single(*domain)
if *shardsPath != "" {
if shards, err = shard.Load(*shardsPath); err != nil {
Expand All @@ -139,6 +144,13 @@ func serve(args []string) error {
st.UseSuspensions(reg.Suspended)
up := uplink.New(st, reg, key, log)
up.Shards = shards
usageSvc, err := statistics.New(reg)
if err != nil {
return err
}
up.StatisticsStore = true
up.Statistics = *statsPolicy == cloudwire.StatsPolicy
usageSvc.Enabled = up.Statistics
// A restart is a deploy, and commands ride through here: phones are
// admitted by the rosters their boxes signed before, without waiting
// for every box to reconnect.
Expand All @@ -154,7 +166,7 @@ func serve(args []string) error {
}
tokens := token.New(st, reg, log)
tokens.Shards = shards
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Fleet: fleetSvc, Log: log}
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Fleet: fleetSvc, Statistics: usageSvc, Log: log}

// Assistants: the authorization server, with agent keys in ftwkeysd.
agentKeys := keys.Dial(*keysSocket)
Expand Down
2 changes: 2 additions & 0 deletions deploy/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ services:
- '443:443/udp'
command:
- -listen=:443
# Set only after the C8 DPIA and policy text have been signed off.
- -statistics-policy=${FTW_CLOUD_STATISTICS_POLICY:-}
- -h3=${FTW_CLOUD_H3:-true}
- -quic-sessions=${FTW_CLOUD_QUIC_SESSIONS:-true}
- -domain=cloud.ftw.energy
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-20261017085842-8a7bd9c6e597
github.com/srcfl/ftw/go/protocol v0.0.0-20261017140802-6335dbcea60e
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-20261017085842-8a7bd9c6e597 h1:oQkKbo+YEhcUYE6IwzSUR5xDvkDOAa/SNQfsI2JJGqQ=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017085842-8a7bd9c6e597/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017140802-6335dbcea60e h1:9J9k985aF1/Drp5GHOZFkLmVq2ImuWRPIzUlXiPyoqY=
github.com/srcfl/ftw/go/protocol v0.0.0-20261017140802-6335dbcea60e/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
46 changes: 46 additions & 0 deletions internal/fleet/public.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package fleet

import (
"encoding/json"
"net/http"
"time"

"github.com/srcfl/ftw/go/protocol/fleetwire"
)

// Public exports only cells already large enough for the project dashboard.
// A day below the minimum has a null count and no dimension labels.
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
}
view, err := s.reg.FleetView(s.now())
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
const minimum = 10
days := make([]map[string]any, 0, len(view.Days))
for _, d := range view.Days {
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{}
if d.Reports >= minimum {
for label, n := range values {
if n >= minimum {
safe[label] = n
}
}
}
row[key] = safe
}
if d.Reports >= minimum {
row["reports"] = d.Reports
}
days = append(days, row)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"schema": "ftw.fleet-public/1", "meaning": fleetwire.Meaning, "observed_at": s.now().UTC().Format(time.RFC3339), "minimum": minimum, "days": days})
}
57 changes: 57 additions & 0 deletions internal/fleet/public_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package fleet

import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"testing"
"time"

"github.com/srcfl/ftw-cloud/internal/registry"
"github.com/srcfl/ftw/go/protocol/fleetwire"
)

func TestPublicFleetHidesSmallDaysAndEachSmallLabel(t *testing.T) {
reg, err := registry.Open(filepath.Join(t.TempDir(), "db"))
if err != nil {
t.Fatal(err)
}
defer reg.Close()
svc, err := New(reg)
if err != nil {
t.Fatal(err)
}
now := time.Now()
svc.now = func() time.Time { return now }
report := fleetwire.Report{Schema: fleetwire.Schema, Version: "v0.147.0", Channel: "stable", Drivers: []string{}, Battery: "none", Zone: "SE4", Age: "unknown"}
for range 9 {
_, err = reg.FleetPut(report, now)
if err != nil {
t.Fatal(err)
}
}
get := func() map[string]any {
w := httptest.NewRecorder()
svc.Public(w, httptest.NewRequest("GET", "/v1/fleet/public", nil))
var v map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
return v
}
v := get()
d := v["days"].([]any)[0].(map[string]any)
if d["reports"] != nil || len(d["ftw_versions"].(map[string]any)) != 0 {
t.Fatal("small day exposed")
}
report.Version = "v0.147.1"
_, _ = reg.FleetPut(report, now)
v = get()
d = v["days"].([]any)[0].(map[string]any)
if d["reports"] != float64(10) || len(d["ftw_versions"].(map[string]any)) != 0 {
t.Fatal("small version exposed")
}
if d["channels"].(map[string]any)["stable"] != float64(10) {
t.Fatal("safe cell hidden")
}
}
Loading
Loading