big_send now forces the Don't-Fragment bit for the whole burst by default, so the largest size that arrives IS the downstream path MTU rather than "fragments got through" — two different measurements the schema already separates. Sizes above our own egress MTU (from the startup self-test) are refused up front and reported as max_df_bytes, because absence caused by our kernel must not be read as a limit of the client's path. Uploads: one JSON file per run under the state dir, with the policy the operator actually cares about — who may upload (off / anonymous / account), how large, how long to keep, and the least anonymization accepted. The profile advertises all of it so the app can present the switch honestly instead of discovering the rules by failing. `account` refuses today rather than falling back to anonymous: picking the strict setting before OIDC lands must not silently mean the loose one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
9.2 KiB
Go
301 lines
9.2 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package runs stores uploaded measurement documents.
|
|
//
|
|
// The premise (and the reason uploads exist at all) is that an engineer runs their own server:
|
|
// uploading a run there is how history, diffing and "it was fine last Tuesday" work. That makes
|
|
// the storage deliberately dumb — one JSON file per run, on disk, greppable, deletable with rm —
|
|
// and puts the interesting policy in two places instead:
|
|
//
|
|
// - who may upload (Policy.Mode), because a public server is a different proposition from a
|
|
// private one; and
|
|
// - how much identifying detail the client must strip first (Policy.MinAnonymization), because
|
|
// someone measuring against a stranger's server should not be shipping their SSIDs there.
|
|
//
|
|
// Retention is enforced on every upload, not by a sweeper, so a server left alone does not grow.
|
|
package runs
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Mode says who may upload.
|
|
type Mode string
|
|
|
|
const (
|
|
// ModeOff refuses every upload. The endpoint still answers, with 403 and a reason, so the
|
|
// app can say "this server does not accept uploads" instead of showing a network error.
|
|
ModeOff Mode = "off"
|
|
// ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already
|
|
// required an admin-minted token, so "anyone enrolled" is not "anyone".
|
|
ModeAnonymous Mode = "anonymous"
|
|
// ModeAccount accepts uploads only from a device tied to a signed-in account. The account
|
|
// system (OIDC) is not built yet, so today this refuses everything with a distinct reason —
|
|
// it exists so operators can pick the strict setting now and have it mean the right thing
|
|
// when accounts land, rather than silently loosening on upgrade.
|
|
ModeAccount Mode = "account"
|
|
)
|
|
|
|
// Anonymization levels, mirroring the client's redaction levels (measurement-schema.md §8).
|
|
// Ordered: full < balanced < strict.
|
|
const (
|
|
AnonFull = "full" // nothing removed — for your own server
|
|
AnonBalanced = "balanced" // network names and device identity pseudonymized, neighbours dropped
|
|
AnonStrict = "strict" // metrics and findings only
|
|
)
|
|
|
|
func anonRank(level string) int {
|
|
switch level {
|
|
case AnonStrict:
|
|
return 2
|
|
case AnonBalanced:
|
|
return 1
|
|
case AnonFull:
|
|
return 0
|
|
}
|
|
return -1 // unknown
|
|
}
|
|
|
|
// Policy is the operator's upload configuration.
|
|
type Policy struct {
|
|
Mode Mode `json:"mode"`
|
|
MaxBytes int64 `json:"max_bytes"`
|
|
RetentionDays int `json:"retention_days"`
|
|
MaxRunsPerDevice int `json:"max_runs_per_device"`
|
|
MinAnonymization string `json:"min_anonymization"`
|
|
}
|
|
|
|
func DefaultPolicy() Policy {
|
|
return Policy{
|
|
Mode: ModeAnonymous,
|
|
MaxBytes: 4 << 20,
|
|
RetentionDays: 90,
|
|
MaxRunsPerDevice: 200,
|
|
MinAnonymization: AnonFull,
|
|
}
|
|
}
|
|
|
|
var (
|
|
ErrDisabled = errors.New("uploads are disabled on this server")
|
|
ErrNeedAccount = errors.New("this server only accepts uploads from signed-in accounts")
|
|
ErrTooLarge = errors.New("run exceeds the server's upload size limit")
|
|
ErrNotAnonEnough = errors.New("run is less anonymized than this server requires")
|
|
ErrMalformed = errors.New("run is not a measurement document")
|
|
)
|
|
|
|
// Meta is the index entry for one stored run — enough to list history without opening the files.
|
|
type Meta struct {
|
|
ID string `json:"id"`
|
|
DeviceID string `json:"device_id"`
|
|
UploadedAt time.Time `json:"uploaded_at"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
Anonymization string `json:"anonymization"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
Verdict string `json:"verdict,omitempty"`
|
|
FindingCount int `json:"finding_count"`
|
|
}
|
|
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
dir string
|
|
policy Policy
|
|
}
|
|
|
|
func Open(stateDir string, p Policy) (*Store, error) {
|
|
dir := filepath.Join(stateDir, "runs")
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Store{dir: dir, policy: p}, nil
|
|
}
|
|
|
|
func (s *Store) Policy() Policy { return s.policy }
|
|
|
|
// Accepts reports whether an upload would be allowed at all, so callers can answer the
|
|
// capability question without a body.
|
|
func (s *Store) Accepts() error {
|
|
switch s.policy.Mode {
|
|
case ModeOff:
|
|
return ErrDisabled
|
|
case ModeAccount:
|
|
return ErrNeedAccount
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Put validates and stores one uploaded document. body is the raw JSON as received: it is stored
|
|
// byte-for-byte so what the device signed off on is what sits on disk.
|
|
func (s *Store) Put(deviceID string, body []byte) (Meta, error) {
|
|
if err := s.Accepts(); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes {
|
|
return Meta{}, ErrTooLarge
|
|
}
|
|
|
|
// Peek at the parts we index on. Unknown fields are ignored: the server must not become a
|
|
// second schema authority that rejects documents a newer client legitimately produces.
|
|
var doc struct {
|
|
Run struct {
|
|
ID string `json:"id"`
|
|
StartedAt string `json:"started_at"`
|
|
Privacy struct {
|
|
Anonymization string `json:"anonymization"`
|
|
} `json:"privacy"`
|
|
} `json:"run"`
|
|
Findings []json.RawMessage `json:"findings"`
|
|
Summary struct {
|
|
Verdict string `json:"verdict"`
|
|
} `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal(body, &doc); err != nil || doc.Run.ID == "" {
|
|
return Meta{}, ErrMalformed
|
|
}
|
|
|
|
level := doc.Run.Privacy.Anonymization
|
|
if level == "" {
|
|
level = AnonFull // no declaration means nothing was stripped
|
|
}
|
|
if anonRank(level) < anonRank(s.policy.MinAnonymization) {
|
|
return Meta{}, fmt.Errorf("%w: got %q, need at least %q",
|
|
ErrNotAnonEnough, level, s.policy.MinAnonymization)
|
|
}
|
|
|
|
id := sanitizeID(doc.Run.ID)
|
|
if id == "" {
|
|
return Meta{}, ErrMalformed
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
devDir := filepath.Join(s.dir, sanitizeID(deviceID))
|
|
if err := os.MkdirAll(devDir, 0o700); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(devDir, id+".json"), body, 0o600); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
|
|
meta := Meta{
|
|
ID: id, DeviceID: deviceID, UploadedAt: time.Now().UTC(),
|
|
StartedAt: doc.Run.StartedAt, Anonymization: level,
|
|
SizeBytes: int64(len(body)), Verdict: doc.Summary.Verdict,
|
|
FindingCount: len(doc.Findings),
|
|
}
|
|
if err := os.WriteFile(filepath.Join(devDir, id+".meta.json"), mustJSON(meta), 0o600); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
s.enforceRetentionLocked(devDir)
|
|
return meta, nil
|
|
}
|
|
|
|
// List returns one device's runs, newest first.
|
|
func (s *Store) List(deviceID string) []Meta {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.listLocked(filepath.Join(s.dir, sanitizeID(deviceID)))
|
|
}
|
|
|
|
// Get returns the stored document bytes for one run.
|
|
func (s *Store) Get(deviceID, runID string) ([]byte, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return os.ReadFile(filepath.Join(s.dir, sanitizeID(deviceID), sanitizeID(runID)+".json"))
|
|
}
|
|
|
|
// Delete removes one run. Missing is not an error: delete is idempotent so a client retrying
|
|
// after a dropped response does not see a spurious failure.
|
|
func (s *Store) Delete(deviceID, runID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
dev, run := sanitizeID(deviceID), sanitizeID(runID)
|
|
for _, suffix := range []string{".json", ".meta.json"} {
|
|
if err := os.Remove(filepath.Join(s.dir, dev, run+suffix)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) listLocked(devDir string) []Meta {
|
|
entries, err := os.ReadDir(devDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
out := make([]Meta, 0, len(entries))
|
|
for _, e := range entries {
|
|
if !strings.HasSuffix(e.Name(), ".meta.json") {
|
|
continue
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(devDir, e.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var m Meta
|
|
if json.Unmarshal(b, &m) == nil {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) })
|
|
return out
|
|
}
|
|
|
|
// enforceRetentionLocked drops runs past the age limit, then past the count limit. Age first, so
|
|
// a burst of uploads cannot push out runs that are still inside the retention window.
|
|
func (s *Store) enforceRetentionLocked(devDir string) {
|
|
metas := s.listLocked(devDir)
|
|
drop := func(m Meta) {
|
|
_ = os.Remove(filepath.Join(devDir, m.ID+".json"))
|
|
_ = os.Remove(filepath.Join(devDir, m.ID+".meta.json"))
|
|
}
|
|
kept := metas[:0]
|
|
if s.policy.RetentionDays > 0 {
|
|
cutoff := time.Now().Add(-time.Duration(s.policy.RetentionDays) * 24 * time.Hour)
|
|
for _, m := range metas {
|
|
if m.UploadedAt.Before(cutoff) {
|
|
drop(m)
|
|
continue
|
|
}
|
|
kept = append(kept, m)
|
|
}
|
|
} else {
|
|
kept = metas
|
|
}
|
|
if s.policy.MaxRunsPerDevice > 0 && len(kept) > s.policy.MaxRunsPerDevice {
|
|
for _, m := range kept[s.policy.MaxRunsPerDevice:] { // listLocked is newest-first
|
|
drop(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sanitizeID keeps ids to characters that cannot escape the directory or collide with the
|
|
// .meta.json suffix convention. Ids are uuids and device ids in practice; anything else is
|
|
// truncated to nothing and rejected upstream.
|
|
func sanitizeID(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
}
|
|
if b.Len() >= 64 {
|
|
break
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func mustJSON(v any) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|