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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
277e33da75
commit
0c5b021b63
@@ -0,0 +1,200 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package compat decides whether two Echolot builds should talk to each other.
|
||||
//
|
||||
// Versions are SemVer. What is actually being checked, though, is not "is this release recent"
|
||||
// but "does this peer speak a wire protocol and schema I understand" — the version is a proxy for
|
||||
// that, and the proxy only holds because we bump the breaking axis when the contract changes.
|
||||
// So the bounds here are set at *breaking boundaries*, not at every release: a patch bump must
|
||||
// never strand a fleet, and the range must not need editing to ship a bugfix.
|
||||
//
|
||||
// The one rule that shapes the rest: refusing a peer must still tell it why. A client that cannot
|
||||
// reach the profile endpoint cannot learn what version it should be, so it has nothing to show its
|
||||
// user but a network error. GET /v1/profile is therefore always reachable, whatever the range says.
|
||||
package compat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Version is a parsed SemVer. Build metadata is discarded (it is explicitly not part of
|
||||
// precedence); pre-release is kept and compared, because "1.0.0-rc1" must sort below "1.0.0".
|
||||
type Version struct {
|
||||
Major, Minor, Patch int
|
||||
Pre string
|
||||
}
|
||||
|
||||
// Parse accepts "1.2.3", "v1.2.3" and our namespaced release tags ("server-v1.2.3"), because
|
||||
// those are the forms that actually reach this code: a tag, a --version output, and an HTTP
|
||||
// header written by three different pieces of software.
|
||||
func Parse(s string) (Version, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
// Strip a tag prefix ending in "v" ("v1.2.3", "server-v1.2.3"). Guarded on the prefix having
|
||||
// no digits so a pre-release identifier that happens to contain a "v" is left alone.
|
||||
if i := strings.LastIndexByte(s, 'v'); i >= 0 && i+1 < len(s) &&
|
||||
s[i+1] >= '0' && s[i+1] <= '9' && !strings.ContainsAny(s[:i], "0123456789") {
|
||||
s = s[i+1:]
|
||||
}
|
||||
if plus := strings.IndexByte(s, '+'); plus >= 0 {
|
||||
s = s[:plus]
|
||||
}
|
||||
var pre string
|
||||
if dash := strings.IndexByte(s, '-'); dash >= 0 {
|
||||
pre, s = s[dash+1:], s[:dash]
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return Version{}, false
|
||||
}
|
||||
out := Version{Pre: pre}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return Version{}, false
|
||||
}
|
||||
switch i {
|
||||
case 0:
|
||||
out.Major = n
|
||||
case 1:
|
||||
out.Minor = n
|
||||
case 2:
|
||||
out.Patch = n
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (v Version) String() string {
|
||||
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
if v.Pre != "" {
|
||||
s += "-" + v.Pre
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Compare returns -1, 0 or 1. A pre-release sorts below the same version without one (SemVer §11);
|
||||
// two pre-releases compare lexically, which is close enough for the identifiers we use.
|
||||
func (v Version) Compare(o Version) int {
|
||||
for _, d := range []int{v.Major - o.Major, v.Minor - o.Minor, v.Patch - o.Patch} {
|
||||
if d != 0 {
|
||||
return sign(d)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case v.Pre == o.Pre:
|
||||
return 0
|
||||
case v.Pre == "":
|
||||
return 1
|
||||
case o.Pre == "":
|
||||
return -1
|
||||
case v.Pre < o.Pre:
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (v Version) Less(o Version) bool { return v.Compare(o) < 0 }
|
||||
|
||||
// NextBreaking is the first version that may break compatibility with v.
|
||||
//
|
||||
// Below 1.0.0 the minor is the breaking axis (SemVer §4: anything may change in 0.y), so 0.4.2's
|
||||
// next break is 0.5.0, not 1.0.0. Getting this wrong in the permissive direction would let a
|
||||
// 0.5 server accept a 0.4 app that cannot speak to it.
|
||||
func (v Version) NextBreaking() Version {
|
||||
if v.Major == 0 {
|
||||
return Version{Major: 0, Minor: v.Minor + 1}
|
||||
}
|
||||
return Version{Major: v.Major + 1}
|
||||
}
|
||||
|
||||
// Range is [Min, Max): minimum inclusive, maximum exclusive. An unset Max means unbounded.
|
||||
//
|
||||
// Exclusive on the upper end because the useful bound is always "the version that broke it",
|
||||
// and writing that literally ("< 1.0.0") is unambiguous in a way that "<= 0.999.999" is not.
|
||||
type Range struct {
|
||||
Min Version
|
||||
Max Version
|
||||
HasMax bool
|
||||
}
|
||||
|
||||
func (r Range) Contains(v Version) bool {
|
||||
if v.Less(r.Min) {
|
||||
return false
|
||||
}
|
||||
if r.HasMax && !v.Less(r.Max) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r Range) String() string {
|
||||
if !r.HasMax {
|
||||
return ">= " + r.Min.String()
|
||||
}
|
||||
return ">= " + r.Min.String() + ", < " + r.Max.String()
|
||||
}
|
||||
|
||||
// ParseRange builds a range from two strings; an empty max means unbounded. A malformed bound is
|
||||
// an error rather than a silently ignored one: a typo in an operator's config must not quietly
|
||||
// turn a restriction off.
|
||||
func ParseRange(min, max string) (Range, error) {
|
||||
lo, ok := Parse(min)
|
||||
if !ok {
|
||||
return Range{}, fmt.Errorf("bad minimum version %q", min)
|
||||
}
|
||||
if strings.TrimSpace(max) == "" {
|
||||
return Range{Min: lo}, nil
|
||||
}
|
||||
hi, ok := Parse(max)
|
||||
if !ok {
|
||||
return Range{}, fmt.Errorf("bad maximum version %q", max)
|
||||
}
|
||||
if hi.Less(lo) || hi.Compare(lo) == 0 {
|
||||
return Range{}, fmt.Errorf("maximum %s is not above minimum %s", max, min)
|
||||
}
|
||||
return Range{Min: lo, Max: hi, HasMax: true}, nil
|
||||
}
|
||||
|
||||
// Verdict is the outcome of a compatibility check.
|
||||
type Verdict int
|
||||
|
||||
const (
|
||||
OK Verdict = iota
|
||||
TooOld
|
||||
TooNew
|
||||
// Unknown means the peer did not say, or said something unparseable — a development build,
|
||||
// or a client too old to send its version at all.
|
||||
Unknown
|
||||
)
|
||||
|
||||
// Check reports whether peer falls in r, and explains the answer in words meant for a person.
|
||||
// The message is deliberately actionable: it names both versions and what to do about it, since
|
||||
// it is the only thing the user on the other end will see.
|
||||
func Check(peer string, r Range, peerName string) (Verdict, string) {
|
||||
v, ok := Parse(peer)
|
||||
if !ok {
|
||||
return Unknown, fmt.Sprintf("%s did not report a usable version (%q); proceeding without a compatibility check", peerName, peer)
|
||||
}
|
||||
switch {
|
||||
case v.Less(r.Min):
|
||||
return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.",
|
||||
peerName, v, r, peerName)
|
||||
case r.HasMax && !v.Less(r.Max):
|
||||
return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or point at a %s within range.",
|
||||
peerName, v, r, peerName)
|
||||
}
|
||||
return OK, ""
|
||||
}
|
||||
|
||||
func sign(d int) int {
|
||||
if d < 0 {
|
||||
return -1
|
||||
}
|
||||
if d > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package compat
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAcceptsTheFormsThatActuallyReachUs(t *testing.T) {
|
||||
cases := map[string]Version{
|
||||
"1.2.3": {Major: 1, Minor: 2, Patch: 3},
|
||||
"v1.2.3": {Major: 1, Minor: 2, Patch: 3},
|
||||
"server-v0.4.2": {Minor: 4, Patch: 2},
|
||||
" 0.2.0 ": {Minor: 2},
|
||||
"1.0.0-rc1": {Major: 1, Pre: "rc1"},
|
||||
"1.0.0+build.7": {Major: 1},
|
||||
"1.0.0-rc1+meta": {Major: 1, Pre: "rc1"},
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, ok := Parse(in)
|
||||
if !ok || got != want {
|
||||
t.Errorf("Parse(%q) = %v,%v; want %v", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
// A pre-release identifier containing a "v" must not be mistaken for a tag prefix.
|
||||
if got, ok := Parse("1.2.3-rcv1"); !ok || got.Pre != "rcv1" || got.Major != 1 {
|
||||
t.Errorf("Parse(1.2.3-rcv1) = %v,%v", got, ok)
|
||||
}
|
||||
for _, bad := range []string{"", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta"} {
|
||||
if _, ok := Parse(bad); ok {
|
||||
t.Errorf("Parse(%q) should have failed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareOrdersPreReleasesBelowTheirRelease(t *testing.T) {
|
||||
lt := func(a, b string) {
|
||||
t.Helper()
|
||||
x, _ := Parse(a)
|
||||
y, _ := Parse(b)
|
||||
if x.Compare(y) != -1 || y.Compare(x) != 1 {
|
||||
t.Errorf("expected %s < %s", a, b)
|
||||
}
|
||||
}
|
||||
lt("0.9.9", "1.0.0")
|
||||
lt("1.0.0", "1.0.1")
|
||||
lt("1.0.0", "1.1.0")
|
||||
lt("1.0.0-rc1", "1.0.0")
|
||||
lt("1.0.0-rc1", "1.0.0-rc2")
|
||||
a, _ := Parse("1.2.3")
|
||||
b, _ := Parse("v1.2.3")
|
||||
if a.Compare(b) != 0 {
|
||||
t.Error("the same version written two ways must compare equal")
|
||||
}
|
||||
}
|
||||
|
||||
// Below 1.0.0 the minor is the breaking axis. Treating 1.0.0 as the next break for a 0.x build
|
||||
// would let a 0.5 server accept a 0.4 client it cannot actually talk to.
|
||||
func TestNextBreakingUsesTheMinorBelowOne(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"0.4.2": "0.5.0",
|
||||
"0.0.9": "0.1.0",
|
||||
"1.2.3": "2.0.0",
|
||||
"2.0.0": "3.0.0",
|
||||
}
|
||||
for in, want := range cases {
|
||||
v, _ := Parse(in)
|
||||
if got := v.NextBreaking().String(); got != want {
|
||||
t.Errorf("NextBreaking(%s) = %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeIsMinInclusiveMaxExclusive(t *testing.T) {
|
||||
r, err := ParseRange("0.2.0", "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := []string{"0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1"}
|
||||
out := []string{"0.1.9", "1.0.0", "1.0.1", "2.0.0"}
|
||||
for _, s := range in {
|
||||
v, _ := Parse(s)
|
||||
if !r.Contains(v) {
|
||||
t.Errorf("%s should be inside %s", s, r)
|
||||
}
|
||||
}
|
||||
for _, s := range out {
|
||||
v, _ := Parse(s)
|
||||
if r.Contains(v) {
|
||||
t.Errorf("%s should be outside %s", s, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnboundedRangeHasNoCeiling(t *testing.T) {
|
||||
r, err := ParseRange("0.2.0", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, _ := Parse("99.0.0")
|
||||
if !r.Contains(v) {
|
||||
t.Error("an empty maximum must mean unbounded")
|
||||
}
|
||||
}
|
||||
|
||||
// A typo in an operator's config must not silently disable the restriction it was meant to set.
|
||||
func TestMalformedBoundsAreErrorsNotSilentPermissiveness(t *testing.T) {
|
||||
for _, c := range [][2]string{
|
||||
{"nonsense", "1.0.0"},
|
||||
{"0.2.0", "nonsense"},
|
||||
{"1.0.0", "0.9.0"}, // max below min
|
||||
{"1.0.0", "1.0.0"}, // empty window: nothing could ever satisfy it
|
||||
} {
|
||||
if _, err := ParseRange(c[0], c[1]); err == nil {
|
||||
t.Errorf("ParseRange(%q, %q) should have failed", c[0], c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExplainsItself(t *testing.T) {
|
||||
r, _ := ParseRange("0.2.0", "1.0.0")
|
||||
|
||||
if v, msg := Check("0.5.0", r, "app"); v != OK || msg != "" {
|
||||
t.Errorf("in-range check should pass silently: %v %q", v, msg)
|
||||
}
|
||||
v, msg := Check("0.1.0", r, "app")
|
||||
if v != TooOld {
|
||||
t.Fatalf("want TooOld, got %v", v)
|
||||
}
|
||||
for _, want := range []string{"0.1.0", "0.2.0", "Update"} {
|
||||
if !contains(msg, want) {
|
||||
t.Errorf("the refusal must name %q so the user can act on it: %q", want, msg)
|
||||
}
|
||||
}
|
||||
if v, _ := Check("1.4.0", r, "app"); v != TooNew {
|
||||
t.Errorf("want TooNew, got %v", v)
|
||||
}
|
||||
|
||||
// A development build reports "dev". Locking developers out of their own server would be a
|
||||
// poor trade for a check that exists to prevent confusing failures.
|
||||
if v, msg := Check("dev", r, "server"); v != Unknown || msg == "" {
|
||||
t.Errorf("unparseable version should be Unknown with an explanation, got %v %q", v, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(h, n string) bool {
|
||||
return len(h) >= len(n) && (h == n || len(n) == 0 || indexOf(h, n) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(h, n string) int {
|
||||
for i := 0; i+len(n) <= len(h); i++ {
|
||||
if h[i:i+len(n)] == n {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -58,6 +58,11 @@ type Config struct {
|
||||
UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device)
|
||||
UploadMinAnon string // ECHOLOT_UPLOAD_MIN_ANONYMIZATION / --upload-min-anonymization
|
||||
|
||||
// Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The
|
||||
// defaults sit at breaking boundaries, so shipping a patch never requires changing them.
|
||||
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
|
||||
MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive)
|
||||
|
||||
// Mode
|
||||
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
|
||||
}
|
||||
@@ -106,6 +111,8 @@ func Load(args []string) (*Config, *Actions, error) {
|
||||
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
|
||||
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
|
||||
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
|
||||
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
|
||||
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
|
||||
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
|
||||
|
||||
fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit")
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"echo-lot.app/server/internal/compat"
|
||||
"echo-lot.app/server/internal/store"
|
||||
)
|
||||
|
||||
func rangeOrDie(t *testing.T, min, max string) compat.Range {
|
||||
t.Helper()
|
||||
r, err := compat.ParseRange(min, max)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestGateRefusesOutOfRangeApps(t *testing.T) {
|
||||
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
|
||||
reached := false
|
||||
h := s.requireCompatibleApp(func(w http.ResponseWriter, _ *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
version string
|
||||
wantCode int
|
||||
wantThru bool
|
||||
}{
|
||||
{"0.2.0", http.StatusOK, true}, // exactly the minimum is in range
|
||||
{"0.9.9", http.StatusOK, true},
|
||||
{"0.1.9", http.StatusUpgradeRequired, false}, // too old
|
||||
{"1.0.0", http.StatusUpgradeRequired, false}, // maximum is exclusive
|
||||
{"2.0.0", http.StatusUpgradeRequired, false}, // too new
|
||||
{"", http.StatusOK, true}, // unknown: allowed, see below
|
||||
{"dev", http.StatusOK, true}, // development build
|
||||
} {
|
||||
reached = false
|
||||
req := httptest.NewRequest("GET", "/v1/sessions", nil)
|
||||
if tc.version != "" {
|
||||
req.Header.Set(AppVersionHeader, tc.version)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
if rec.Code != tc.wantCode || reached != tc.wantThru {
|
||||
t.Errorf("app %q: got code=%d reached=%v, want code=%d reached=%v",
|
||||
tc.version, rec.Code, reached, tc.wantCode, tc.wantThru)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal that does not say what version to install is only marginally better than a timeout.
|
||||
func TestRefusalNamesTheAcceptedWindow(t *testing.T) {
|
||||
s := &Server{AppRange: rangeOrDie(t, "0.2.0", "1.0.0")}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/v1/runs", nil)
|
||||
req.Header.Set(AppVersionHeader, "0.1.0")
|
||||
s.requireCompatibleApp(func(http.ResponseWriter, *http.Request) {})(rec, req)
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("refusal body is not JSON: %v", err)
|
||||
}
|
||||
for _, key := range []string{"error", "app_version", "accepts_app", "protocol_version"} {
|
||||
if body[key] == nil || body[key] == "" {
|
||||
t.Errorf("refusal omits %q, so the client cannot explain itself: %v", key, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body["accepts_app"].(string), "0.2.0") {
|
||||
t.Errorf("accepts_app should state the minimum: %v", body["accepts_app"])
|
||||
}
|
||||
}
|
||||
|
||||
// The profile is how a refused client learns which version it needs. Gating it would leave the
|
||||
// user with a network error instead of an answer, which defeats the whole check.
|
||||
func TestProfileIsReachableRegardlessOfVersion(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{
|
||||
AppRange: rangeOrDie(t, "9.0.0", ""), // nothing current could satisfy this
|
||||
Store: st,
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/v1/profile", nil)
|
||||
req.Header.Set(AppVersionHeader, "0.1.0")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
// The request carries no credential, so the handler answers 401 — the point is that it is
|
||||
// the *handler* answering, not the version gate turning it into 426.
|
||||
if rec.Code == http.StatusUpgradeRequired {
|
||||
t.Fatal("the profile endpoint must never be gated on app version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroValueRangeFallsBackToTheBuiltInDefault(t *testing.T) {
|
||||
s := &Server{} // nothing configured
|
||||
got := s.appRange()
|
||||
want := DefaultAppRange()
|
||||
if got.Min != want.Min || got.HasMax != want.HasMax || got.Max != want.Max {
|
||||
t.Fatalf("appRange() = %s, want the built-in default %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"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"
|
||||
@@ -70,22 +71,87 @@ type Server struct {
|
||||
// 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()
|
||||
mux.HandleFunc("POST /v1/enroll", s.enroll)
|
||||
// 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)
|
||||
mux.HandleFunc("POST /v1/sessions", s.newSession)
|
||||
mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession)
|
||||
mux.HandleFunc("GET /v1/sessions/{id}/observations", s.observations)
|
||||
mux.HandleFunc("POST /v1/sessions/{id}/actions", s.actions)
|
||||
mux.HandleFunc("POST /v1/echo", s.httpEcho)
|
||||
mux.HandleFunc("GET /v1/tls-reference", s.tlsReference)
|
||||
mux.HandleFunc("POST /v1/runs", s.uploadRun)
|
||||
mux.HandleFunc("GET /v1/runs", s.listRuns)
|
||||
mux.HandleFunc("GET /v1/runs/{id}", s.getRun)
|
||||
mux.HandleFunc("DELETE /v1/runs/{id}", s.deleteRun)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -424,6 +490,14 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
|
||||
// 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()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -574,3 +648,12 @@ func (s *Server) deleteRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -177,13 +177,14 @@ func (s *Server) mtuAck(conn *net.UDPConn, raddr netip.AddrPort, sess *session.S
|
||||
}
|
||||
|
||||
// Observation block (spec §3.3), fixed 40 bytes appended to the RESP header:
|
||||
// 0 8 t_rx_ns (server clock, process epoch)
|
||||
// 8 8 t_tx_ns
|
||||
// 16 16 observed source IP (v4-mapped when v4)
|
||||
// 32 2 observed source port
|
||||
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
|
||||
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
||||
// 36 4 received size
|
||||
//
|
||||
// 0 8 t_rx_ns (server clock, process epoch)
|
||||
// 8 8 t_tx_ns
|
||||
// 16 16 observed source IP (v4-mapped when v4)
|
||||
// 32 2 observed source port
|
||||
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
|
||||
// 35 1 received DSCP/ECN byte (0xFF = not observed)
|
||||
// 36 4 received size
|
||||
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
|
||||
b := make([]byte, 40)
|
||||
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
|
||||
|
||||
Reference in New Issue
Block a user