Files
echolot/server/internal/control/control.go
T
mrambossekandClaude Fable 5 0c5b021b63
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
compat: SemVer version windows between app and server
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:

  protocol_version  — CAN these builds talk. The correctness axis. Below 1.0.0
                      the minor is the breaking axis, per SemVer §4.
  release window    — MAY they, per policy. [min, max), advertised in the
                      profile, overridable by the operator.

The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.

Three rules that shape the rest:

  - GET /v1/profile is never gated. It is where a refused client learns which
    version it needs; gating it leaves the user with a network error instead of
    an answer, which is precisely the confusion this exists to remove.
  - An unparseable or absent version is "unknown", and is allowed. Development
    builds report "dev", and a client too old to send the header cannot be
    identified anyway.
  - Bounds sit at breaking boundaries, not at releases, so shipping a patch
    never requires editing a range. The app's server minimum is 0.4.2 for a
    stated reason: earlier multi-homed servers mis-addressed granted sends and
    the client measured 100% downstream loss that never happened.

The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:36:34 +02:00

660 lines
23 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package control implements the control plane (spec §2): enrollment,
// profile, sessions. HTTPS with a possibly self-signed cert — clients trust
// the SPKI pin from enrollment, not a CA (spec §1).
package control
import (
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store"
)
// Version is stamped by the build (-ldflags "-X ...").
var Version = "dev"
type Server struct {
Store *store.Store
Sessions *session.Manager
Name string
// Targets/capabilities for the profile response. The server offers only
// what it actually implements; the registry grows with the code.
UDPPort int
TCPPort int
StunPort int
// SPKI pin of the serving cert, for the profile's pins[] field.
PinB64 string
// CertChain is the served leaf-first DER chain, for GET /v1/tls-reference.
CertChain [][]byte
// Capabilities as computed at startup from what is actually wired up.
Capabilities []string
// TCPRecent returns recent TCP-echo connections for a source IP (may be nil).
TCPRecent func(ip string) any
// DelayedEcho schedules/sends a DELAYED_ECHO for a session (may be nil).
DelayedEcho func(sess *session.Session, actionID string) error
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
Runs *runs.Store
// EgressMTU reports the server's own measured egress path MTU (0 = unknown). With DF set
// we cannot emit a datagram larger than this, so requested sizes above it are refused up
// front and reported as such — the client must not read that as a downstream path limit.
EgressMTU func() int
// CanaryQueries returns logged canary lookups for a session prefix (may be nil).
CanaryQueries func(sessionPrefix string) any
// CanaryZone is surfaced in the profile so the app knows what to query.
CanaryZone string
// ProvenGood reports the server's self-test signal (may be nil). Surfaced
// in the profile so a client can trust — or skip — MTU tests: if the
// server's own egress isn't full-MTU, client MTU results measure the
// server, not the client.
ProvenGood func() (mtuOK, sysctlOK bool)
// AppRange is the app-version window this server will serve. Zero value means the built-in
// default (see DefaultAppRange).
AppRange compat.Range
}
// AppVersionHeader is how a client states its version. A client too old to send it is treated as
// unknown rather than refused: the check exists to turn confusing failures into clear ones, and
// refusing something we cannot identify achieves the opposite.
const AppVersionHeader = "X-Echolot-App-Version"
// ProtocolVersion is the wire contract (probe-protocol.md) this build implements. It is what the
// version window is really about; the release version is only a proxy for it.
const ProtocolVersion = "1.0.0"
// SchemaVersion is the measurement-document format this server can store.
const SchemaVersion = "1.0.0"
// DefaultAppRange: everything from the first app that speaks this protocol up to — but not
// including — the next breaking series. Bounds sit at breaking boundaries so shipping a patch
// never requires touching this.
func DefaultAppRange() compat.Range {
r, err := compat.ParseRange("0.2.0", "1.0.0")
if err != nil {
panic("built-in app range is malformed: " + err.Error())
}
return r
}
func (s *Server) appRange() compat.Range {
if s.AppRange.Min == (compat.Version{}) && !s.AppRange.HasMax {
return DefaultAppRange()
}
return s.AppRange
}
// requireCompatibleApp wraps a handler with the version window.
//
// Deliberately NOT applied to GET /v1/profile: that is where a client learns which version it
// should be. Gating it would leave a refused client with nothing to show its user but a timeout,
// which is precisely the confusion this check exists to remove.
func (s *Server) requireCompatibleApp(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
verdict, msg := compat.Check(r.Header.Get(AppVersionHeader), s.appRange(), "app")
switch verdict {
case compat.TooOld, compat.TooNew:
slog.Info("refused incompatible app", "app_version", r.Header.Get(AppVersionHeader),
"accepts", s.appRange().String(), "path", r.URL.Path)
// 426 says exactly this and nothing else; the body carries the window so the app can
// show the user the number to reach, not just that it failed.
writeJSON(w, http.StatusUpgradeRequired, map[string]any{
"error": msg,
"app_version": r.Header.Get(AppVersionHeader),
"accepts_app": s.appRange().String(),
"server_version": Version,
"protocol_version": ProtocolVersion,
})
return
}
next(w, r)
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Always reachable, whatever the version window says: this is how a client discovers the
// window it has to satisfy.
mux.HandleFunc("GET /v1/profile", s.profile)
gate := s.requireCompatibleApp
mux.HandleFunc("POST /v1/enroll", gate(s.enroll))
mux.HandleFunc("POST /v1/sessions", gate(s.newSession))
mux.HandleFunc("DELETE /v1/sessions/{id}", gate(s.deleteSession))
mux.HandleFunc("GET /v1/sessions/{id}/observations", gate(s.observations))
mux.HandleFunc("POST /v1/sessions/{id}/actions", gate(s.actions))
mux.HandleFunc("POST /v1/echo", gate(s.httpEcho))
mux.HandleFunc("GET /v1/tls-reference", gate(s.tlsReference))
mux.HandleFunc("POST /v1/runs", gate(s.uploadRun))
mux.HandleFunc("GET /v1/runs", gate(s.listRuns))
mux.HandleFunc("GET /v1/runs/{id}", gate(s.getRun))
mux.HandleFunc("DELETE /v1/runs/{id}", gate(s.deleteRun))
// TODO(spec §5): frag_send, throughput (both build on the same grant machinery)
return mux
}
// EchoHandler exposes just the HTTP-echo endpoint for the optional cleartext
// listener (spec §4: plaintext-path tampering test).
func (s *Server) EchoHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/echo", s.httpEcho)
return mux
}
// selftestSignal is the compact "server proven good" object for the profile.
// mtu_ok=false tells a client its MTU results would measure this server.
func selftestSignal(f func() (bool, bool)) map[string]any {
if f == nil {
return map[string]any{"mtu_ok": nil, "sysctl_ok": nil}
}
mtuOK, sysctlOK := f()
return map[string]any{"mtu_ok": mtuOK, "sysctl_ok": sysctlOK}
}
// sessionAuth resolves {id} and requires the bearer to be the owning device.
func (s *Server) sessionAuth(w http.ResponseWriter, r *http.Request) *session.Session {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return nil
}
sess := s.Sessions.ByID(r.PathValue("id"))
if sess == nil || sess.Device != dev.ID {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such session"})
return nil
}
return sess
}
// observations is spec §6 — everything the server witnessed for a session.
func (s *Server) observations(w http.ResponseWriter, r *http.Request) {
sess := s.sessionAuth(w, r)
if sess == nil {
return
}
packetsSeen, udp, cb := sess.Observations()
var tcp any
if s.TCPRecent != nil {
// Correlate by source IP: TCP echo carries no session id on the wire.
if ds := sess.DataSource(); ds.IsValid() {
tcp = s.TCPRecent(ds.Addr().Unmap().String())
} else if sess.ControlSource.IsValid() {
tcp = s.TCPRecent(sess.ControlSource.Unmap().String())
}
}
var dnsCanary any
if s.CanaryQueries != nil {
dnsCanary = s.CanaryQueries(sess.ID[:16]) // the session's wire prefix
}
writeJSON(w, http.StatusOK, map[string]any{
"udp": map[string]any{"packets_seen": packetsSeen, "packets": udp},
"tcp": tcp,
"connect_back": cb,
"dns_canary": dnsCanary,
// TODO(spec §6): http echo records
})
}
// actions is spec §5 — authenticated asymmetric operations. Implemented:
// delayed_echo, connect_back. Destination is ALWAYS the session's observed
// source (data-plane source; connect_back uses the control-plane source).
func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
sess := s.sessionAuth(w, r)
if sess == nil {
return
}
var req struct {
Action string `json:"action"`
DelayS int `json:"delay_s"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Count int `json:"count"`
SizeBytes int `json:"size_bytes"`
IntervalUs int `json:"interval_us"`
SizesBytes []int `json:"sizes_bytes"`
DF *bool `json:"df"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad body"})
return
}
actionID := randomID()
switch req.Action {
case "delayed_echo":
if s.DelayedEcho == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "delayed_echo not wired"})
return
}
delay := min(max(req.DelayS, 1), 600)
if !sess.DataSource().IsValid() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "no data-plane traffic seen yet — send an ECHO first"})
return
}
time.AfterFunc(time.Duration(delay)*time.Second, func() {
if err := s.DelayedEcho(sess, actionID); err != nil {
slog.Debug("delayed echo failed", "err", err)
}
})
writeJSON(w, http.StatusAccepted, map[string]any{"action_id": actionID, "delay_s": delay})
case "connect_back":
if req.Port < 1 || req.Port > 65535 || (req.Protocol != "tcp" && req.Protocol != "udp") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "connect_back needs protocol tcp|udp and a port"})
return
}
target := net.JoinHostPort(sess.ControlSource.Unmap().String(), strconv.Itoa(req.Port))
go func() {
start := time.Now()
conn, err := net.DialTimeout(req.Protocol, target, 5*time.Second)
res := session.ConnectBackResult{ActionID: actionID, RttMs: float64(time.Since(start).Microseconds()) / 1000}
switch {
case err == nil:
res.Result = "connected"
if req.Protocol == "udp" {
// UDP "dial" always succeeds locally; send one datagram
// so the client actually observes something.
_, _ = conn.Write([]byte("echolot-connect-back " + actionID))
}
conn.Close()
case isTimeout(err):
res.Result = "timeout"
default:
res.Result = "refused"
}
sess.RecordConnectBack(res)
}()
writeJSON(w, http.StatusAccepted, map[string]any{"action_id": actionID, "target": target})
case "downtrain":
if s.DownTrain == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "downtrain not wired"})
return
}
// A downstream train sends far more than it receives, so it needs a grant (§3.4).
count := clamp(req.Count, 1, 5000)
size := clamp(req.SizeBytes, dataMinPacket, 1500)
interval := clamp(req.IntervalUs, 0, 1_000_000)
g := sess.NewGrant(actionID, int64(count*size), 0, session.DefaultGrantLimits)
if g == nil {
writeJSON(w, http.StatusConflict, noDataPlaneYet)
return
}
go func() {
sent, err := s.DownTrain(sess, g, count, size, interval)
slog.Info("downtrain finished", "action", actionID, "sent", sent, "bytes", g.Sent(), "err", err)
}()
writeJSON(w, http.StatusAccepted, map[string]any{
"action_id": actionID, "count": count, "size_bytes": size, "interval_us": interval,
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
})
case "big_send":
if s.BigSend == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "big_send not wired"})
return
}
sizes := req.SizesBytes
if len(sizes) == 0 {
sizes = []int{1200, 1400, 1472, 1500, 2000, 4000}
}
if len(sizes) > 32 {
sizes = sizes[:32]
}
// DF on by default: an unfragmented burst is what makes the result a path-MTU
// measurement rather than a fragment-delivery one. Callers opt out explicitly.
df := true
if req.DF != nil {
df = *req.DF
}
// With DF we can only emit up to our own egress MTU minus IP+UDP headers. Drop the
// rest here and say so, rather than sending nothing and letting the client blame
// the path.
maxDF := 0
if df {
maxDF = s.maxDFPayload(sess)
if maxDF > 0 {
kept := sizes[:0]
for _, x := range sizes {
if x <= maxDF {
kept = append(kept, x)
}
}
sizes = kept
}
}
if len(sizes) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "every requested size exceeds the server's own egress MTU with DF set",
"max_df_bytes": maxDF,
})
return
}
total := 0
for _, x := range sizes {
total += clamp(x, dataMinPacket, 9000)
}
g := sess.NewGrant(actionID, int64(total), 0, session.DefaultGrantLimits)
if g == nil {
writeJSON(w, http.StatusConflict, noDataPlaneYet)
return
}
go func() {
results, err := s.BigSend(sess, g, sizes, df)
slog.Info("big_send finished", "action", actionID, "results", results, "df", df, "err", err)
}()
writeJSON(w, http.StatusAccepted, map[string]any{
"action_id": actionID, "sizes_bytes": sizes, "df": df, "max_df_bytes": maxDF,
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown or unimplemented action"})
}
}
// maxDFPayload is the largest UDP payload the server can emit toward this session without
// fragmenting: its own egress MTU less the IP and UDP headers of the session's address family.
// Returns 0 when the egress MTU is unknown, meaning "do not clamp".
func (s *Server) maxDFPayload(sess *session.Session) int {
if s.EgressMTU == nil {
return 0
}
mtu := s.EgressMTU()
if mtu <= 0 {
return 0
}
overhead := 28 // IPv4 (20) + UDP (8)
if src := sess.DataSource(); src.IsValid() && !src.Addr().Unmap().Is4() {
overhead = 48 // IPv6 (40) + UDP (8)
}
return mtu - overhead
}
// dataMinPacket is the smallest datagram that still carries a header + a little payload.
const dataMinPacket = 40
var noDataPlaneYet = map[string]string{
"error": "no data-plane traffic seen yet — send an ECHO first so the destination is verified",
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
func randomID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
if v, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(v)
}
return ""
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// enroll redeems a single-use enrollment token for a device credential (§2.1).
func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing enrollment token"})
return
}
var body struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
dev, err := s.Store.Redeem(tok, body.Name)
if err != nil {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
slog.Info("device enrolled", "device", dev.ID, "name", dev.Name)
writeJSON(w, http.StatusCreated, map[string]string{
"device_id": dev.ID,
"credential": dev.Credential, // returned exactly once
})
}
// profile is spec §2.2. Only implemented capabilities are advertised.
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
host := r.Host
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
writeJSON(w, http.StatusOK, map[string]any{
"profile_version": 1,
"name": s.Name,
"server_version": Version,
// source_url makes GPL §6 compliance mechanical for operators of
// modified builds and gives clients provenance for the measurement.
"source_url": "", // TODO: stamp from build metadata
"capabilities": s.Capabilities,
"targets": []map[string]any{{
"id": s.Name,
"ip4": host, // TODO: explicit configured addresses, v6, second STUN addr
"udp_port": s.UDPPort,
"tcp_port": s.TCPPort,
"stun_port": s.StunPort,
}},
"pins": []string{"pin-sha256:" + s.PinB64},
"next_pins": []string{},
"canary_zone": s.CanaryZone,
"server_selftest": selftestSignal(s.ProvenGood),
"limits": map[string]any{"max_kbps": 50000, "max_session_s": 900},
// The app needs the upload rules before it offers the switch: whether uploads are
// accepted at all, and how much identifying detail it must strip first.
"uploads": s.uploadPolicy(),
// What this build speaks, and which app versions it will serve. A client checks the
// server side of the same question against its own bounds.
"compat": map[string]any{
"protocol_version": ProtocolVersion,
"schema_version": SchemaVersion,
"app_min": s.appRange().Min.String(),
"app_max": maxOrEmpty(s.appRange()),
},
})
}
// newSession is spec §2.4.
func (s *Server) newSession(w http.ResponseWriter, r *http.Request) {
cred := bearer(r)
dev := s.Store.DeviceByCredential(cred)
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
srcHost, _, _ := strings.Cut(r.RemoteAddr, ":")
src, _ := netip.ParseAddr(strings.Trim(srcHost, "[]"))
sess, salt, err := s.Sessions.New(dev.ID, cred, src)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"session_id": sess.ID,
"key_salt": base64.StdEncoding.EncodeToString(salt),
"epoch": sess.Epoch.Format(time.RFC3339),
"expires_s": int(time.Until(sess.Expires).Seconds()),
})
}
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
if s.Store.DeviceByCredential(bearer(r)) == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if id := r.PathValue("id"); len(id) >= 16 {
s.Sessions.Delete(id)
}
w.WriteHeader(http.StatusNoContent)
}
// SpkiPinB64 computes the RFC 7469 pin (SHA-256 over the SPKI, base64) of the
// leaf certificate — what enrollment QR codes carry and profiles re-state.
func SpkiPinB64(cert tls.Certificate) (string, error) {
leaf := cert.Leaf
if leaf == nil {
parsed, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return "", err
}
leaf = parsed
}
sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo)
return base64.StdEncoding.EncodeToString(sum[:]), nil
}
// uploadPolicy is the profile's advertisement of the operator's upload rules.
func (s *Server) uploadPolicy() map[string]any {
if s.Runs == nil {
return map[string]any{"mode": string(runs.ModeOff), "reason": "not configured"}
}
p := s.Runs.Policy()
return map[string]any{
"mode": string(p.Mode),
"max_bytes": p.MaxBytes,
"retention_days": p.RetentionDays,
"max_runs_per_device": p.MaxRunsPerDevice,
"min_anonymization": p.MinAnonymization,
}
}
// uploadRun stores one measurement document for the calling device.
func (s *Server) uploadRun(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if s.Runs == nil {
writeJSON(w, http.StatusForbidden, map[string]string{"error": runs.ErrDisabled.Error()})
return
}
limit := s.Runs.Policy().MaxBytes
if limit <= 0 {
limit = 4 << 20
}
// +1 so a body exactly at the limit is distinguishable from one over it.
body, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read failed"})
return
}
meta, err := s.Runs.Put(dev.ID, body)
switch {
case err == nil:
slog.Info("run uploaded", "device", dev.ID, "run", meta.ID,
"bytes", meta.SizeBytes, "anon", meta.Anonymization, "findings", meta.FindingCount)
writeJSON(w, http.StatusCreated, meta)
case errors.Is(err, runs.ErrDisabled), errors.Is(err, runs.ErrNeedAccount),
errors.Is(err, runs.ErrNotAnonEnough):
writeJSON(w, http.StatusForbidden, map[string]any{
"error": err.Error(), "uploads": s.uploadPolicy(),
})
case errors.Is(err, runs.ErrTooLarge):
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{
"error": err.Error(), "max_bytes": s.Runs.Policy().MaxBytes,
})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}
}
func (s *Server) listRuns(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil || s.Runs == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
list := s.Runs.List(dev.ID)
if list == nil {
list = []runs.Meta{}
}
writeJSON(w, http.StatusOK, map[string]any{"runs": list})
}
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil || s.Runs == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
// Scoped to the calling device's own directory: one device cannot read another's runs by
// guessing a run id.
b, err := s.Runs.Get(dev.ID, r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"})
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(b)
}
func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil || s.Runs == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if err := s.Runs.Delete(dev.ID, r.PathValue("id")); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
// maxOrEmpty renders an unbounded ceiling as "" rather than as a sentinel version, so a client
// reading the profile cannot mistake a placeholder for a real bound.
func maxOrEmpty(r compat.Range) string {
if !r.HasMax {
return ""
}
return r.Max.String()
}