The settings card offered three editable boxes and said nothing about the server itself — which addresses a test will actually use, on which ports, what it can measure. That is the part a person checks before trusting a result, and "which address did this come from" is precisely the question a report leaves open. The server now publishes it. The profile's targets carried one IPv4 and a TODO; it reports both families and both alternates, derived from the UDP listen spec rather than configured separately, so the list cannot drift from what is actually bound. No reservation means no alternate is claimed: announcing a second address as the RFC 5780 alternate when none was set aside would promise a redirect the server will not send. The app renders them read-only, in a panel visibly distinct from the fields above. An editable box that changes nothing is worse than no box, and these are facts to read rather than settings to apply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
995 lines
37 KiB
Go
995 lines
37 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 (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"echo-lot.app/server/internal/compat"
|
|
"echo-lot.app/server/internal/dataplane"
|
|
"echo-lot.app/server/internal/oidc"
|
|
"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)
|
|
// OIDC verifies ID tokens presented by the *app* (may be nil).
|
|
OIDC *oidc.Verifier
|
|
// AdminOIDC verifies tokens from the admin UI's own client. Separate because an IdP may
|
|
// give each application its own issuer — Authentik derives it from the application slug —
|
|
// and a verifier pins exactly one issuer and the clients belonging to it.
|
|
AdminOIDC *oidc.Verifier
|
|
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
|
|
Runs *runs.Store
|
|
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
|
|
// needs a raw socket, so it is unavailable to an unprivileged server).
|
|
FragSend func(sess *session.Session, g *session.Grant, sizeBytes int, mode dataplane.FragMode, fragSize int) (dataplane.FragResult, error)
|
|
// DownThroughput sends paced traffic toward the client for a bounded time (may be nil).
|
|
DownThroughput func(sess *session.Session, g *session.Grant, durationMs, kbps, sizeBytes int) (dataplane.ThroughputResult, error)
|
|
// 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
|
|
// The addresses this server can be measured on. The "_alt" pair is the second address
|
|
// RFC 5780 behaviour discovery redirects to, and the one reserved from services so that
|
|
// nothing answering there is itself a measurement.
|
|
IP4, IP6, IP4Alt, IP6Alt 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)
|
|
|
|
// PublicControlURL is where clients reach this server, for the enrollment link (§2.1).
|
|
// Empty means "derive from the address we are listening on", which is right for a plain
|
|
// deployment and wrong behind a proxy or a name — hence the override.
|
|
PublicControlURL string
|
|
// 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))
|
|
mux.HandleFunc("POST /v1/account/link", gate(s.linkAccount))
|
|
mux.HandleFunc("DELETE /v1/account/link", gate(s.unlinkAccount))
|
|
mux.HandleFunc("GET /v1/account", gate(s.accountStatus))
|
|
// 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,
|
|
// The sender's own count, which is what makes the receiver's count mean something.
|
|
"throughput": sess.ThroughputReports(),
|
|
// The receiver's count for upstream runs — same idea, other direction.
|
|
"throughput_up": upstreamJSON(sess),
|
|
"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"`
|
|
Mode string `json:"mode"`
|
|
FragBytes int `json:"frag_bytes"`
|
|
Direction string `json:"direction"`
|
|
DurationS int `json:"duration_s"`
|
|
Kbps int `json:"kbps"`
|
|
Streams int `json:"streams"`
|
|
}
|
|
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},
|
|
})
|
|
|
|
case "frag_send":
|
|
if s.FragSend == nil {
|
|
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
|
"error": "frag_send needs a raw socket, which this server does not have",
|
|
})
|
|
return
|
|
}
|
|
size := clamp(req.SizeBytes, 1600, 8000) // must exceed the path MTU or nothing fragments
|
|
mode := dataplane.FragMode(req.Mode)
|
|
switch mode {
|
|
case dataplane.FragInOrder, dataplane.FragReversed, dataplane.FragFirstLast:
|
|
default:
|
|
mode = dataplane.FragInOrder
|
|
}
|
|
fragBytes := clamp(req.FragBytes, 8, 1400)
|
|
g := sess.NewGrant(actionID, int64(size), 0, session.DefaultGrantLimits)
|
|
if g == nil {
|
|
writeJSON(w, http.StatusConflict, noDataPlaneYet)
|
|
return
|
|
}
|
|
// Synchronous: the whole burst is a few kB and at most a few hundred milliseconds, and
|
|
// the caller wants to know it was actually emitted before it starts listening. An
|
|
// asynchronous send would make "nothing arrived" ambiguous between a path drop and a
|
|
// send that never happened — the one distinction this test exists to make.
|
|
result, err := s.FragSend(sess, g, size, mode, fragBytes)
|
|
slog.Info("frag_send finished", "action", actionID, "mode", mode,
|
|
"size", size, "fragments", result.Fragments, "err", err)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": err.Error(), "action_id": actionID, "result": result,
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"action_id": actionID, "mode": string(mode), "size_bytes": size,
|
|
"frag_bytes": fragBytes, "fragments": result.Fragments,
|
|
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
|
|
})
|
|
|
|
case "throughput":
|
|
if s.DownThroughput == nil {
|
|
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "throughput not wired"})
|
|
return
|
|
}
|
|
// Only the downstream direction needs the server to send. Upstream is the client
|
|
// sending and the server counting, which needs no action at all — so asking for it here
|
|
// is a client bug worth naming rather than silently doing the other thing.
|
|
if req.Direction == "up" {
|
|
// Upstream needs nothing sent from here — the client generates the traffic and the
|
|
// server counts it. The only thing an action can usefully do is zero the counter so
|
|
// the run measures itself rather than inheriting an earlier one.
|
|
sess.ResetUpstream()
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"action_id": actionID, "direction": "up", "reset": true,
|
|
"note": "send TYPE_THROUGHPUT_UP packets, then read observations.throughput_up",
|
|
})
|
|
return
|
|
}
|
|
if req.Direction != "" && req.Direction != "down" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "direction must be up or down",
|
|
})
|
|
return
|
|
}
|
|
// Planned once, here, so the response promises exactly what the run will do. A request
|
|
// that would outlast the server's byte cap comes back with a shorter duration rather
|
|
// than being truncated halfway.
|
|
durationMs, kbps := dataplane.ThroughputPlan(
|
|
clamp(req.DurationS, 1, 30)*1000, clamp(req.Kbps, 100, 200_000))
|
|
size := clamp(req.SizeBytes, dataMinPacket, 1472)
|
|
if req.SizeBytes == 0 {
|
|
size = 1200
|
|
}
|
|
g := sess.NewGrant(actionID, 0, kbps, dataplane.ThroughputLimits(durationMs, kbps))
|
|
if g == nil {
|
|
writeJSON(w, http.StatusConflict, noDataPlaneYet)
|
|
return
|
|
}
|
|
// Answered before the run so the client can start listening, then reported through the
|
|
// observations API. Doing it the other way round would have the client miss the first
|
|
// second of a ten-second test.
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"action_id": actionID, "direction": "down",
|
|
"duration_s": durationMs / 1000, "duration_ms": durationMs,
|
|
"requested_duration_s": clamp(req.DurationS, 1, 30),
|
|
"kbps": kbps, "size_bytes": size,
|
|
"grant": map[string]any{"max_bytes": g.MaxBytes, "max_kbps": g.MaxKbps},
|
|
})
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
go func() {
|
|
result, err := s.DownThroughput(sess, g, durationMs, kbps, size)
|
|
slog.Info("throughput finished", "action", actionID, "packets", result.Packets,
|
|
"bytes", result.Bytes, "kbps", result.Kbps, "limited_by", result.LimitedBy, "err", err)
|
|
sess.RecordThroughput(actionID, result.Packets, result.Bytes, result.DurationMs,
|
|
result.Kbps, result.LimitedBy)
|
|
}()
|
|
|
|
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)
|
|
enc := json.NewEncoder(w)
|
|
// Go escapes <, > and & by default, for JSON embedded in HTML. This is an API, and the
|
|
// escaping is actively harmful here: a refusal message reading "needs >= 0.2.0" is what
|
|
// the user ends up seeing. Nothing we emit is ever interpolated into a page.
|
|
enc.SetEscapeHTML(false)
|
|
_ = enc.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,
|
|
// The spec (§2.1) names this device_credential; the first implementation shipped
|
|
// "credential". Both are sent while deployed 0.5.x clients still read the old name;
|
|
// the client prefers the spec's. Drop "credential" once nothing reads it.
|
|
"device_credential": dev.Credential, // returned exactly once
|
|
"credential": dev.Credential, // deprecated alias, see above
|
|
})
|
|
}
|
|
|
|
// 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{s.target(host)},
|
|
"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 a client needs to start a sign-in, without hard-coding the operator's IdP into
|
|
// the app: where to authorize, which client id to use, and whether it is worth offering
|
|
// sign-in at all on this server.
|
|
"auth": s.authInfo(r.Context()),
|
|
// 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, dev.LinkedToAccount())
|
|
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.ListFor(s.visibleDevices(dev))
|
|
if list == nil {
|
|
list = []runs.Meta{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"runs": list,
|
|
// Says whose history this is, so a client can show "3 devices" rather than leaving the
|
|
// user to wonder why runs from another phone appeared.
|
|
"scope": map[string]any{
|
|
"account_id": dev.AccountID,
|
|
"devices": len(s.visibleDevices(dev)),
|
|
},
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
// Resolved against the caller's own devices only, so a run id from another account is not
|
|
// found rather than being fetched from wherever it happens to live.
|
|
owner, ok := s.Runs.OwnerOf(s.visibleDevices(dev), r.PathValue("id"))
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such run"})
|
|
return
|
|
}
|
|
b, err := s.Runs.Get(owner, 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
|
|
}
|
|
owner, ok := s.Runs.OwnerOf(s.visibleDevices(dev), r.PathValue("id"))
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNoContent) // delete is idempotent; absent is the desired state
|
|
return
|
|
}
|
|
if err := s.Runs.Delete(owner, 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()
|
|
}
|
|
|
|
// EnrollmentLink builds the §2.1 bootstrap string for a freshly minted token.
|
|
//
|
|
// The server assembles it rather than the operator, because it is the only party that knows all
|
|
// three parts at once — its own URL, its own SPKI pin, and the token. An operator copying a pin
|
|
// by hand is the step that goes wrong, and a pin wrong by one character does not fail loudly.
|
|
func (s *Server) EnrollmentLink(token string) string {
|
|
return EnrollmentURI(s.PublicControlURL, s.PinB64, token)
|
|
}
|
|
|
|
// EnrollmentURI is the same assembly without a running server, for the mint-a-link CLI action.
|
|
// Shared rather than reimplemented: two copies of this encoding would eventually disagree, and
|
|
// the failure mode is a pin that looks right and produces an inscrutable TLS error.
|
|
func EnrollmentURI(publicURL, pinB64, token string) string {
|
|
return "echolot://enroll?v=1" +
|
|
"&u=" + url.QueryEscape(strings.TrimRight(publicURL, "/")) +
|
|
"&p=" + url.QueryEscape("pin-sha256:"+pinB64) +
|
|
"&t=" + url.QueryEscape(token)
|
|
}
|
|
|
|
// target describes where this server can be measured, so a client can say which address a result
|
|
// came from instead of "the server".
|
|
//
|
|
// The alternates matter as much as the primaries: RFC 5780 behaviour discovery needs a second
|
|
// address to redirect to, and an operator reading a report needs to know which of their addresses
|
|
// a finding refers to. [fallback] is used only when nothing was configured explicitly, so a server
|
|
// that has not been told its own addresses still answers with something usable.
|
|
func (s *Server) target(fallback string) map[string]any {
|
|
t := map[string]any{
|
|
"id": s.Name,
|
|
"udp_port": s.UDPPort,
|
|
"tcp_port": s.TCPPort,
|
|
"stun_port": s.StunPort,
|
|
}
|
|
ip4 := s.IP4
|
|
if ip4 == "" {
|
|
ip4 = fallback
|
|
}
|
|
for k, v := range map[string]string{
|
|
"ip4": ip4, "ip6": s.IP6, "ip4_alt": s.IP4Alt, "ip6_alt": s.IP6Alt,
|
|
} {
|
|
if v != "" {
|
|
t[k] = v
|
|
}
|
|
}
|
|
return t
|
|
}
|
|
|
|
// upstreamJSON renders the upstream tally with the derived figures already computed, so every
|
|
// consumer does not have to repeat (and risk fumbling) the same arithmetic.
|
|
func upstreamJSON(sess *session.Session) map[string]any {
|
|
u := sess.Upstream()
|
|
return map[string]any{
|
|
"packets": u.Packets, "bytes": u.Bytes,
|
|
"span_ms": u.SpanMs(), "kbps": u.Kbps(),
|
|
}
|
|
}
|
|
|
|
// authInfo advertises the sign-in configuration, so the app can present a Sign in button only
|
|
// when there is something behind it, and can drive the flow without the user typing an issuer URL.
|
|
func (s *Server) authInfo(ctx context.Context) map[string]any {
|
|
if s.OIDC == nil || !s.OIDC.Config().Enabled() {
|
|
return map[string]any{"enabled": false}
|
|
}
|
|
cfg := s.OIDC.Config()
|
|
out := map[string]any{
|
|
"enabled": true,
|
|
"issuer": cfg.Issuer,
|
|
// The app's client, not the server's: this is what a phone should authorize as.
|
|
"client_id": cfg.AppClientID,
|
|
// The app is a public client on a phone: no secret can be kept, so PKCE is what
|
|
// protects the code exchange (RFC 7636), and the redirect comes back through the
|
|
// scheme the app already registers for enrollment links.
|
|
"flow": "authorization_code+pkce",
|
|
"redirect_uri": "echolot://auth",
|
|
"scopes": "openid profile email",
|
|
}
|
|
if d, err := s.OIDC.Discover(ctx); err == nil {
|
|
out["authorization_endpoint"] = d.AuthorizationEndpoint
|
|
out["token_endpoint"] = d.TokenEndpoint
|
|
out["end_session_endpoint"] = d.EndSessionEndpoint
|
|
} else {
|
|
// Reported rather than hidden: an unreachable IdP is the operator's problem to see, and
|
|
// a client that knows the difference can say "sign-in is configured but the provider is
|
|
// not answering" instead of failing obscurely.
|
|
out["discovery_error"] = err.Error()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// linkAccount ties the calling device to the person whose ID token it presents.
|
|
//
|
|
// The device credential proves *which device*; the ID token proves *which person*. Both are
|
|
// required, and neither substitutes for the other: enrollment admits a device to the server,
|
|
// signing in attributes it to someone.
|
|
func (s *Server) linkAccount(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.OIDC == nil || !s.OIDC.Config().Enabled() {
|
|
writeJSON(w, http.StatusNotImplemented, map[string]string{
|
|
"error": "this server has no identity provider configured, so there is nothing to sign in to",
|
|
})
|
|
return
|
|
}
|
|
var body struct {
|
|
IDToken string `json:"id_token"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.IDToken == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected an id_token"})
|
|
return
|
|
}
|
|
claims, err := s.OIDC.Verify(r.Context(), body.IDToken)
|
|
if err != nil {
|
|
// Deliberately terse to the client and detailed in the log: a caller probing token
|
|
// handling should not be told which check it failed.
|
|
slog.Info("rejected sign-in", "device", dev.ID, "err", err)
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "the identity token was not accepted"})
|
|
return
|
|
}
|
|
if err := s.Store.LinkAccount(dev.ID, claims.AccountID(), claims.Display()); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
slog.Info("device linked to account", "device", dev.ID, "account", claims.AccountID(),
|
|
"admin", s.OIDC.IsAdmin(claims))
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"account_id": claims.AccountID(), "display_name": claims.Display(),
|
|
"admin": s.OIDC.IsAdmin(claims),
|
|
})
|
|
}
|
|
|
|
// unlinkAccount signs out on this device. The device stays enrolled: signing out should not cost
|
|
// someone their enrollment, which an operator had to grant.
|
|
func (s *Server) unlinkAccount(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 err := s.Store.LinkAccount(dev.ID, "", ""); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) accountStatus(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
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"signed_in": dev.LinkedToAccount(),
|
|
"account_id": dev.AccountID,
|
|
"display_name": dev.AccountName,
|
|
"device_id": dev.ID,
|
|
})
|
|
}
|
|
|
|
// visibleDevices is the set of devices whose runs the caller may read.
|
|
//
|
|
// Signed in: every device on the same account, which is what an account is for. Not signed in:
|
|
// only itself — anonymous devices are not a group, and treating the absent account as a shared
|
|
// one would let any of them read all the others.
|
|
func (s *Server) visibleDevices(dev *store.Device) []string {
|
|
if dev.LinkedToAccount() {
|
|
if ids := s.Store.DeviceIDsForAccount(dev.AccountID); len(ids) > 0 {
|
|
return ids
|
|
}
|
|
}
|
|
return []string{dev.ID}
|
|
}
|