enrollment: the server mints the §2.1 bootstrap link, the app consumes it
POST /admin/enroll-tokens now returns the whole link, not just the token: echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token> The server is the only party that knows all three parts at once, and the part an operator gets wrong by hand is the base64 pin — which does not fail loudly, it just never matches, surfacing days later as an inscrutable TLS error. The app takes the link from a paste or from an echolot:// deep link (QR scan), and writes URL, pin and credential together or not at all. One trap the tests pin: an unencoded "+" in a query string decodes to a space, so a hand-assembled link arrives with a pin wrong by one character. Base64 has no spaces, so they are restored — unambiguous, and it cannot damage a correctly encoded pin. Also fixes a spec divergence: §2.1 names the field device_credential and the first implementation shipped "credential". Both are sent now and the client prefers the spec's; the alias goes once nothing reads it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8166611af1
commit
ad85f3bfcd
@@ -31,6 +31,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -145,12 +146,13 @@ func serve(cfg *config.Config) error {
|
||||
Store: st, Sessions: sessions, Name: cfg.Name,
|
||||
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
|
||||
StunPort: mustPort(firstAddr(cfg.StunListen)), PinB64: pin, CertChain: cert.Certificate,
|
||||
DelayedEcho: dp.SendDelayedEcho,
|
||||
DownTrain: dp.DownTrain,
|
||||
BigSend: dp.BigSend,
|
||||
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
|
||||
Runs: runStore,
|
||||
AppRange: appRange,
|
||||
DelayedEcho: dp.SendDelayedEcho,
|
||||
DownTrain: dp.DownTrain,
|
||||
BigSend: dp.BigSend,
|
||||
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
|
||||
Runs: runStore,
|
||||
AppRange: appRange,
|
||||
PublicControlURL: publicControlURL(cfg),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -446,3 +448,24 @@ func loadOrCreateCert(cfg *config.Config) (tls.Certificate, error) {
|
||||
slog.Info("generated self-signed certificate", "cert", certPath)
|
||||
return tls.X509KeyPair(certPem, keyPem)
|
||||
}
|
||||
|
||||
// publicControlURL is where clients should reach this server's control plane.
|
||||
//
|
||||
// Configured wins; otherwise the first control listen address is used, which is correct for the
|
||||
// plain case (bind an address, hand out that address). A wildcard bind has no single right answer,
|
||||
// so it is left to the operator rather than guessed — a link pointing at 0.0.0.0 is worse than a
|
||||
// link the operator was told to configure.
|
||||
func publicControlURL(cfg *config.Config) string {
|
||||
if cfg.PublicControlURL != "" {
|
||||
return strings.TrimRight(cfg.PublicControlURL, "/")
|
||||
}
|
||||
addr := firstAddr(cfg.ControlListen)
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(addr, ":") || strings.HasPrefix(addr, "0.0.0.0:") || strings.HasPrefix(addr, "[::]:") {
|
||||
slog.Warn("control plane is bound to a wildcard address; set ECHOLOT_PUBLIC_URL "+
|
||||
"so enrollment links point somewhere reachable", "listen", addr)
|
||||
}
|
||||
return "https://" + addr
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ type Config struct {
|
||||
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
|
||||
MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive)
|
||||
|
||||
// Where clients reach the control plane, for enrollment links. Empty = derive from the
|
||||
// first control listen address.
|
||||
PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url
|
||||
|
||||
// Mode
|
||||
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
|
||||
}
|
||||
@@ -111,6 +115,7 @@ 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.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
|
||||
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)")
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -72,6 +73,10 @@ type Server struct {
|
||||
// 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
|
||||
@@ -456,8 +461,12 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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
|
||||
"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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -662,3 +671,16 @@ func maxOrEmpty(r compat.Range) string {
|
||||
}
|
||||
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 {
|
||||
u := s.PublicControlURL
|
||||
return "echolot://enroll?v=1" +
|
||||
"&u=" + url.QueryEscape(strings.TrimRight(u, "/")) +
|
||||
"&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) +
|
||||
"&t=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user