From 77510a9dbc80315be2cf7919619710d89471df6c Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 4 May 2026 15:00:45 -0600 Subject: [PATCH 01/63] Add peer module + portforward for "Share My Connection" client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of 4 stacked PRs implementing the radiance side of "Share My Connection" (peer-proxy). This PR introduces a self-contained peer module — wiring into LocalBackend / settings / FFI lands in PRs 2-4. * portforward: UPnP IGDv2 with IGDv1 fallback (huin/goupnp). Forwarder exposes MapPort, UnmapPort, StartRenewal (50%-of-lease cadence, 1-min floor), and ExternalIP. Each goupnp call is wrapped in a ctx-respecting helper so an unresponsive gateway can't block the caller past its deadline. * peer.Client: orchestrates one session — open UPnP port → fetch external IP → register with lantern-cloud → start a second sing-box instance with the server-supplied config → run the heartbeat loop. Stop deregisters, closes the box, unmaps the port, and continues past individual failures so partial state never lingers. The box's lifetime ctx is derived from Background (not the Start caller's ctx) so a short-lived Start ctx doesn't kill it. * peer.API: thin HTTP client for /v1/peer/{register,heartbeat, deregister}. X-Lantern-Device-Id is sent on every request so the server can owner-gate. * heartbeatLoop auto-stops on a 404 from the server (registration reaped or wrong owner). Stop runs in a separate goroutine to avoid the cyclic Stop → cancelRun → loop-exit deadlock. Tests cover the happy path, every failure phase (port-forward, external-IP, register, sing-box build, sing-box start), Stop idempotency, Stop continuing past individual errors, the 404 auto-stop path, and the transient-error stays-running path. portforward gets fake-IGD coverage including ctx-cancellation. go test -race and golangci-lint are clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 2 +- peer/api.go | 114 +++++++++ peer/peer.go | 311 ++++++++++++++++++++++++ peer/peer_test.go | 418 ++++++++++++++++++++++++++++++++ portforward/portforward.go | 290 ++++++++++++++++++++++ portforward/portforward_test.go | 234 ++++++++++++++++++ 6 files changed, 1368 insertions(+), 1 deletion(-) create mode 100644 peer/api.go create mode 100644 peer/peer.go create mode 100644 peer/peer_test.go create mode 100644 portforward/portforward.go create mode 100644 portforward/portforward_test.go diff --git a/go.mod b/go.mod index 05ccb784..228186c0 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/huin/goupnp v1.3.0 github.com/knadh/koanf/parsers/json v1.0.0 github.com/knadh/koanf/providers/rawbytes v1.0.0 github.com/knadh/koanf/v2 v2.3.0 @@ -132,7 +133,6 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect - github.com/huin/goupnp v1.3.0 // indirect github.com/illarion/gonotify/v2 v2.0.3 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect diff --git a/peer/api.go b/peer/api.go new file mode 100644 index 00000000..45d298c5 --- /dev/null +++ b/peer/api.go @@ -0,0 +1,114 @@ +// Package peer implements the client side of "Share My Connection". api.go +// is the thin HTTP client for lantern-cloud's /v1/peer/* endpoints. +package peer + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type RegisterRequest struct { + ExternalIP string `json:"external_ip"` + ExternalPort uint16 `json:"external_port"` + InternalPort uint16 `json:"internal_port"` +} + +type RegisterResponse struct { + RouteID string `json:"route_id"` + ServerConfig string `json:"server_config"` + HeartbeatIntervalSeconds int64 `json:"heartbeat_interval_seconds"` +} + +type LifecycleRequest struct { + RouteID string `json:"route_id"` +} + +// APIError carries the server's HTTP status and body. Callers map specific +// statuses to user-facing errors (404 → not registered, 422 → not reachable +// from the public internet, 503 → feature off). +type APIError struct { + Status int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("peer api: status=%d body=%s", e.Status, e.Body) +} + +type API struct { + httpClient *http.Client + baseURL string + deviceID string +} + +// NewAPI constructs the client. baseURL must not have a trailing slash and +// must not include "/v1" — that's appended per-endpoint. +func NewAPI(httpClient *http.Client, baseURL, deviceID string) *API { + return &API{httpClient: httpClient, baseURL: baseURL, deviceID: deviceID} +} + +func (a *API) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) { + var resp RegisterResponse + if err := a.do(ctx, http.MethodPost, "/v1/peer/register", req, &resp); err != nil { + return nil, fmt.Errorf("register: %w", err) + } + return &resp, nil +} + +// Heartbeat extends the peer route's TTL. The server owner-gates via +// X-Lantern-Device-Id, so a leaked route_id can't be used by another device +// to keep the registration alive. +func (a *API) Heartbeat(ctx context.Context, routeID string) error { + if err := a.do(ctx, http.MethodPost, "/v1/peer/heartbeat", LifecycleRequest{RouteID: routeID}, nil); err != nil { + return fmt.Errorf("heartbeat: %w", err) + } + return nil +} + +func (a *API) Deregister(ctx context.Context, routeID string) error { + if err := a.do(ctx, http.MethodPost, "/v1/peer/deregister", LifecycleRequest{RouteID: routeID}, nil); err != nil { + return fmt.Errorf("deregister: %w", err) + } + return nil +} + +func (a *API) do(ctx context.Context, method, path string, body, out any) error { + var reqBody io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + reqBody = bytes.NewReader(buf) + } + r, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, reqBody) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + if body != nil { + r.Header.Set("Content-Type", "application/json") + } + r.Header.Set("X-Lantern-Device-Id", a.deviceID) + + resp, err := a.httpClient.Do(r) + if err != nil { + return fmt.Errorf("send: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + const maxBody = 4096 + buf, _ := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + return &APIError{Status: resp.StatusCode, Body: string(bytes.TrimSpace(buf))} + } + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} diff --git a/peer/peer.go b/peer/peer.go new file mode 100644 index 00000000..c5668054 --- /dev/null +++ b/peer/peer.go @@ -0,0 +1,311 @@ +package peer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/rand/v2" + "sync" + "time" + + "github.com/sagernet/sing-box/experimental/libbox" + + "github.com/getlantern/radiance/portforward" +) + +// Lower bound avoids well-known/registered ports; upper bound stays below the +// typical OS ephemeral range so the OS isn't likely to assign the same port +// to another local process. +const ( + internalPortMin = 30000 + internalPortMax = 50000 +) + +type portForwarder interface { + MapPort(ctx context.Context, internalPort uint16, description string) (*portforward.Mapping, error) + UnmapPort(ctx context.Context) error + StartRenewal(ctx context.Context) + ExternalIP(ctx context.Context) (string, error) +} + +type boxService interface { + Start() error + Close() error +} + +type boxFactory func(ctx context.Context, options string) (boxService, error) + +type Status struct { + Active bool `json:"active"` + SharingSince time.Time `json:"sharing_since,omitempty"` + ExternalIP string `json:"external_ip,omitempty"` + ExternalPort uint16 `json:"external_port,omitempty"` + RouteID string `json:"route_id,omitempty"` +} + +// Config plumbs in dependencies. Zero-valued fields fall back to production +// defaults; HeartbeatInterval and HeartbeatTimeout exist so tests can drive +// the loop without sleeping a full minute. +type Config struct { + API *API + NewForwarder func(ctx context.Context) (portForwarder, error) + BuildBoxService boxFactory + HeartbeatInterval time.Duration + HeartbeatTimeout time.Duration +} + +// Client orchestrates one peer-proxy session: open UPnP port → register with +// lantern-cloud → run a sing-box samizdat inbound on the forwarded port → +// heartbeat → on shutdown: deregister + close inbound + unmap. +// +// Re-Starting a stopped Client is allowed. +type Client struct { + cfg Config + + mu sync.Mutex + active bool + status Status + cancelRun context.CancelFunc + runDone chan struct{} + forwarder portForwarder + box boxService + routeID string +} + +func NewClient(cfg Config) (*Client, error) { + if cfg.API == nil { + return nil, errors.New("peer: Config.API is required") + } + if cfg.NewForwarder == nil { + cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { + return portforward.NewForwarder(ctx) + } + } + if cfg.BuildBoxService == nil { + cfg.BuildBoxService = defaultBuildBoxService + } + if cfg.HeartbeatTimeout == 0 { + cfg.HeartbeatTimeout = 30 * time.Second + } + return &Client{cfg: cfg}, nil +} + +// Start opens the peer-proxy session. On success a background heartbeat +// goroutine is running; on error any partial setup is torn down before +// returning. +func (c *Client) Start(ctx context.Context) error { + c.mu.Lock() + if c.active { + c.mu.Unlock() + return errors.New("peer client already active") + } + c.mu.Unlock() + + fwd, err := c.cfg.NewForwarder(ctx) + if err != nil { + return fmt.Errorf("discover gateway: %w", err) + } + internalPort := pickInternalPort() + mapping, err := fwd.MapPort(ctx, internalPort, "Lantern Share My Connection") + if err != nil { + return fmt.Errorf("map port %d: %w", internalPort, err) + } + + externalIP, err := fwd.ExternalIP(ctx) + if err != nil { + _ = fwd.UnmapPort(ctx) + return fmt.Errorf("get external ip: %w", err) + } + regResp, err := c.cfg.API.Register(ctx, RegisterRequest{ + ExternalIP: externalIP, + ExternalPort: mapping.ExternalPort, + InternalPort: mapping.InternalPort, + }) + if err != nil { + _ = fwd.UnmapPort(ctx) + return fmt.Errorf("register with lantern-cloud: %w", err) + } + + // runCtx must outlive Start, so it derives from Background() rather than + // the caller's ctx — otherwise libbox's stored ctx would die when Start + // returns and take the box's internal goroutines with it. + runCtx, cancelRun := context.WithCancel(context.Background()) + box, err := c.cfg.BuildBoxService(runCtx, regResp.ServerConfig) + if err != nil { + cancelRun() + _ = c.cfg.API.Deregister(ctx, regResp.RouteID) + _ = fwd.UnmapPort(ctx) + return fmt.Errorf("build sing-box: %w", err) + } + if err := box.Start(); err != nil { + cancelRun() + _ = box.Close() + _ = c.cfg.API.Deregister(ctx, regResp.RouteID) + _ = fwd.UnmapPort(ctx) + return fmt.Errorf("start sing-box: %w", err) + } + + heartbeat := c.cfg.HeartbeatInterval + if heartbeat == 0 { + heartbeat = time.Duration(regResp.HeartbeatIntervalSeconds) * time.Second + if heartbeat < time.Minute { + heartbeat = 5 * time.Minute + } + } + runDone := make(chan struct{}) + + c.mu.Lock() + c.active = true + c.forwarder = fwd + c.box = box + c.routeID = regResp.RouteID + c.cancelRun = cancelRun + c.runDone = runDone + c.status = Status{ + Active: true, + SharingSince: time.Now(), + ExternalIP: externalIP, + ExternalPort: mapping.ExternalPort, + RouteID: regResp.RouteID, + } + c.mu.Unlock() + + fwd.StartRenewal(runCtx) + go c.heartbeatLoop(runCtx, heartbeat, runDone) + + slog.Info("peer client started", + "external_ip", externalIP, + "external_port", mapping.ExternalPort, + "internal_port", mapping.InternalPort, + "method", mapping.Method, + "route_id", regResp.RouteID, + "heartbeat", heartbeat, + ) + return nil +} + +// Stop tears down an active session. Idempotent. Blocks until the heartbeat +// goroutine has exited and all teardown calls have completed (or timed out). +func (c *Client) Stop(ctx context.Context) error { + c.mu.Lock() + if !c.active { + c.mu.Unlock() + return nil + } + cancel := c.cancelRun + done := c.runDone + fwd := c.forwarder + box := c.box + routeID := c.routeID + c.active = false + c.cancelRun = nil + c.runDone = nil + c.forwarder = nil + c.box = nil + c.routeID = "" + c.status = Status{} + c.mu.Unlock() + + cancel() + <-done + + var firstErr error + if err := c.cfg.API.Deregister(ctx, routeID); err != nil { + firstErr = fmt.Errorf("deregister: %w", err) + slog.Warn("peer client deregister failed (continuing teardown)", "err", err) + } + if err := box.Close(); err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("close sing-box: %w", err) + } + slog.Warn("peer client sing-box close failed", "err", err) + } + if err := fwd.UnmapPort(ctx); err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("unmap port: %w", err) + } + slog.Warn("peer client unmap port failed", "err", err) + } + slog.Info("peer client stopped", "route_id", routeID) + return firstErr +} + +func (c *Client) IsActive() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.active +} + +func (c *Client) CurrentStatus() Status { + c.mu.Lock() + defer c.mu.Unlock() + return c.status +} + +// heartbeatLoop closes done on exit so Stop can wait for the loop before +// tearing down resources. The channel is passed in rather than read off the +// Client because Stop nils c.runDone before waiting on its local copy. +func (c *Client) heartbeatLoop(ctx context.Context, interval time.Duration, done chan struct{}) { + defer close(done) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.mu.Lock() + routeID := c.routeID + c.mu.Unlock() + if routeID == "" { + return + } + hbCtx, cancel := context.WithTimeout(ctx, c.cfg.HeartbeatTimeout) + err := c.cfg.API.Heartbeat(hbCtx, routeID) + cancel() + if err != nil { + // A single transient blip shouldn't kill the registration — + // the server-side reaper will deprecate the row if heartbeats + // stay missing past expiration, and we'll observe that on a + // later heartbeat as a 404. + slog.Warn("peer heartbeat failed", "err", err, "route_id", routeID) + if isNotRegistered(err) { + slog.Info("peer route no longer registered server-side, stopping client") + // Stop runs in a separate goroutine to avoid the cyclic + // Stop → cancelRun → loop-exit deadlock. + go func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = c.Stop(stopCtx) + }() + return + } + } + } + } +} + +// isNotRegistered reports whether an error from the heartbeat is a 404 from +// the server (deprecated / reaped / wrong owner). On 404 the registration is +// gone and we stop ourselves; on any other error we keep trying. +func isNotRegistered(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.Status == 404 +} + +func pickInternalPort() uint16 { + return uint16(internalPortMin + rand.IntN(internalPortMax-internalPortMin)) +} + +// We pass a nil PlatformInterface — peer-proxy inbounds don't need TUN / +// platform-VPN integration the way the main VPN tunnel does. The samizdat +// inbound is just an HTTPS server bound to a TCP port; sing-box's default +// network stack handles it. +func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) { + bs, err := libbox.NewServiceWithContext(ctx, options, nil) + if err != nil { + return nil, fmt.Errorf("libbox.NewServiceWithContext: %w", err) + } + return bs, nil +} diff --git a/peer/peer_test.go b/peer/peer_test.go new file mode 100644 index 00000000..8addf5f6 --- /dev/null +++ b/peer/peer_test.go @@ -0,0 +1,418 @@ +package peer + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getlantern/radiance/portforward" +) + +type fakeForwarder struct { + mu sync.Mutex + mapErr error + extIPErr error + unmapErr error + mapped bool + unmapped bool + renewals int + externalIP string + mapping *portforward.Mapping + cancelRenew context.CancelFunc +} + +func (f *fakeForwarder) MapPort(_ context.Context, internalPort uint16, _ string) (*portforward.Mapping, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.mapErr != nil { + return nil, f.mapErr + } + f.mapped = true + f.mapping = &portforward.Mapping{ + ExternalPort: internalPort, + InternalPort: internalPort, + InternalIP: "192.168.1.10", + Protocol: "TCP", + LeaseDuration: time.Hour, + Method: "fake", + } + return f.mapping, nil +} + +func (f *fakeForwarder) UnmapPort(_ context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + f.unmapped = true + return f.unmapErr +} + +func (f *fakeForwarder) StartRenewal(ctx context.Context) { + f.mu.Lock() + f.renewals++ + rctx, cancel := context.WithCancel(ctx) + f.cancelRenew = cancel + f.mu.Unlock() + go func() { <-rctx.Done() }() +} + +func (f *fakeForwarder) ExternalIP(_ context.Context) (string, error) { + if f.extIPErr != nil { + return "", f.extIPErr + } + if f.externalIP == "" { + return "203.0.113.99", nil + } + return f.externalIP, nil +} + +func (f *fakeForwarder) wasUnmapped() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.unmapped +} + +func (f *fakeForwarder) wasMapped() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.mapped +} + +type fakeBoxService struct { + startErr error + closeErr error + started atomic.Bool + closed atomic.Bool + gotConfig string +} + +func (b *fakeBoxService) Start() error { + if b.startErr != nil { + return b.startErr + } + b.started.Store(true) + return nil +} + +func (b *fakeBoxService) Close() error { + b.closed.Store(true) + return b.closeErr +} + +type stubServer struct { + t *testing.T + server *httptest.Server + registerStatus int + registerResp RegisterResponse + heartbeatStatus int + deregisterStatus int + registerCount atomic.Int64 + heartbeatCount atomic.Int64 + deregisterCount atomic.Int64 + registerDeviceID atomic.Value // string + heartbeatDeviceID atomic.Value // string + deregisterDeviceID atomic.Value // string + lastRegisterReq atomic.Value // RegisterRequest +} + +func newStubServer(t *testing.T) *stubServer { + t.Helper() + s := &stubServer{ + t: t, + registerStatus: http.StatusOK, + heartbeatStatus: http.StatusOK, + deregisterStatus: http.StatusOK, + registerResp: RegisterResponse{ + RouteID: "00000000-0000-0000-0000-000000000123", + ServerConfig: `{"inbounds": [{"type":"samizdat","tag":"samizdat-in"}]}`, + HeartbeatIntervalSeconds: 60, + }, + } + mux := http.NewServeMux() + mux.HandleFunc("/v1/peer/register", func(w http.ResponseWriter, r *http.Request) { + s.registerCount.Add(1) + s.registerDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) + var req RegisterRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.lastRegisterReq.Store(req) + if s.registerStatus != http.StatusOK { + http.Error(w, "register failed", s.registerStatus) + return + } + _ = json.NewEncoder(w).Encode(s.registerResp) + }) + mux.HandleFunc("/v1/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { + s.heartbeatCount.Add(1) + s.heartbeatDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) + if s.heartbeatStatus != http.StatusOK { + http.Error(w, "heartbeat failed", s.heartbeatStatus) + return + } + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/v1/peer/deregister", func(w http.ResponseWriter, r *http.Request) { + s.deregisterCount.Add(1) + s.deregisterDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) + if s.deregisterStatus != http.StatusOK { + http.Error(w, "deregister failed", s.deregisterStatus) + return + } + w.WriteHeader(http.StatusOK) + }) + s.server = httptest.NewServer(mux) + t.Cleanup(s.server.Close) + return s +} + +// newTestClient builds a Client wired to the supplied test doubles. The +// HeartbeatInterval default of 0 leaves the production floor in place +// (caller can override per test). +func newTestClient(t *testing.T, fwd portForwarder, box *fakeBoxService, srv *stubServer, opts ...func(*Config)) *Client { + t.Helper() + cfg := Config{ + API: NewAPI(srv.server.Client(), srv.server.URL, "test-device"), + NewForwarder: func(_ context.Context) (portForwarder, error) { + return fwd, nil + }, + BuildBoxService: func(_ context.Context, options string) (boxService, error) { + box.gotConfig = options + return box, nil + }, + } + for _, opt := range opts { + opt(&cfg) + } + c, err := NewClient(cfg) + require.NoError(t, err) + return c +} + +func TestClient_Start_HappyPath(t *testing.T) { + fwd := &fakeForwarder{externalIP: "203.0.113.42"} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + ctx := context.Background() + require.NoError(t, c.Start(ctx)) + t.Cleanup(func() { _ = c.Stop(ctx) }) + + assert.True(t, c.IsActive()) + assert.True(t, fwd.wasMapped()) + assert.True(t, box.started.Load()) + assert.Equal(t, int64(1), srv.registerCount.Load()) + assert.Equal(t, "test-device", srv.registerDeviceID.Load()) + + req := srv.lastRegisterReq.Load().(RegisterRequest) + assert.Equal(t, "203.0.113.42", req.ExternalIP) + assert.NotZero(t, req.ExternalPort) + assert.NotZero(t, req.InternalPort) + + status := c.CurrentStatus() + assert.True(t, status.Active) + assert.Equal(t, "203.0.113.42", status.ExternalIP) + assert.Equal(t, "00000000-0000-0000-0000-000000000123", status.RouteID) +} + +func TestClient_Start_DoubleStartIsError(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + require.NoError(t, c.Start(context.Background())) + t.Cleanup(func() { _ = c.Stop(context.Background()) }) + + err := c.Start(context.Background()) + assert.ErrorContains(t, err, "already active") +} + +func TestClient_Start_PortForwardFailureUnwinds(t *testing.T) { + fwd := &fakeForwarder{mapErr: portforward.ErrNoPortForwarding} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + err := c.Start(context.Background()) + require.Error(t, err) + assert.False(t, c.IsActive()) + assert.Equal(t, int64(0), srv.registerCount.Load()) + assert.False(t, box.started.Load()) +} + +func TestClient_Start_ExternalIPFailureUnwinds(t *testing.T) { + fwd := &fakeForwarder{extIPErr: errors.New("gateway returned empty")} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + err := c.Start(context.Background()) + require.Error(t, err) + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped(), "port must be unmapped after external-ip failure") + assert.Equal(t, int64(0), srv.registerCount.Load()) + assert.False(t, box.started.Load()) +} + +func TestClient_Start_RegisterFailureUnwinds(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{} + srv := newStubServer(t) + srv.registerStatus = http.StatusUnprocessableEntity + c := newTestClient(t, fwd, box, srv) + + err := c.Start(context.Background()) + require.Error(t, err) + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped()) + assert.False(t, box.started.Load()) +} + +func TestClient_Start_BoxStartFailureUnwinds(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{startErr: errors.New("boom")} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + err := c.Start(context.Background()) + require.Error(t, err) + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped()) + assert.True(t, box.closed.Load()) + assert.Equal(t, int64(1), srv.deregisterCount.Load()) +} + +func TestClient_Stop_HappyPath(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + ctx := context.Background() + require.NoError(t, c.Start(ctx)) + require.NoError(t, c.Stop(ctx)) + + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped()) + assert.True(t, box.closed.Load()) + assert.Equal(t, int64(1), srv.deregisterCount.Load()) + assert.Equal(t, "test-device", srv.deregisterDeviceID.Load()) +} + +func TestClient_Stop_IsIdempotent(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + ctx := context.Background() + require.NoError(t, c.Start(ctx)) + require.NoError(t, c.Stop(ctx)) + require.NoError(t, c.Stop(ctx)) + assert.Equal(t, int64(1), srv.deregisterCount.Load()) +} + +// Stop continues teardown even if individual steps fail. The first error is +// returned; the others are logged. All resources still get released. +func TestClient_Stop_ContinuesPastIndividualErrors(t *testing.T) { + fwd := &fakeForwarder{unmapErr: errors.New("router said no")} + box := &fakeBoxService{closeErr: errors.New("box close failed")} + srv := newStubServer(t) + srv.deregisterStatus = http.StatusInternalServerError + c := newTestClient(t, fwd, box, srv) + + ctx := context.Background() + require.NoError(t, c.Start(ctx)) + err := c.Stop(ctx) + require.Error(t, err) + assert.ErrorContains(t, err, "deregister") + + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped()) + assert.True(t, box.closed.Load()) + assert.Equal(t, int64(1), srv.deregisterCount.Load()) +} + +// Drives the loop with a 50ms interval (overridden via Config.HeartbeatInterval) +// against a server that always 404s, then waits for the auto-stop goroutine to +// flip IsActive() false and run teardown. +func TestClient_Heartbeat_404TriggersAutoStop(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{} + srv := newStubServer(t) + srv.heartbeatStatus = http.StatusNotFound + c := newTestClient(t, fwd, box, srv, func(cfg *Config) { + cfg.HeartbeatInterval = 50 * time.Millisecond + cfg.HeartbeatTimeout = 1 * time.Second + }) + + require.NoError(t, c.Start(context.Background())) + + deadline := time.After(3 * time.Second) + for c.IsActive() { + select { + case <-deadline: + t.Fatal("client did not auto-stop within 3s") + case <-time.After(20 * time.Millisecond): + } + } + + assert.GreaterOrEqual(t, srv.heartbeatCount.Load(), int64(1)) + assert.Equal(t, "test-device", srv.heartbeatDeviceID.Load()) + assert.Equal(t, int64(1), srv.deregisterCount.Load()) + assert.True(t, fwd.wasUnmapped()) + assert.True(t, box.closed.Load()) +} + +// Non-404 heartbeat errors must not tear the client down — they're logged +// and the loop keeps trying. +func TestClient_Heartbeat_TransientErrorDoesNotStop(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{} + srv := newStubServer(t) + srv.heartbeatStatus = http.StatusInternalServerError + c := newTestClient(t, fwd, box, srv, func(cfg *Config) { + cfg.HeartbeatInterval = 50 * time.Millisecond + cfg.HeartbeatTimeout = 1 * time.Second + }) + + require.NoError(t, c.Start(context.Background())) + t.Cleanup(func() { _ = c.Stop(context.Background()) }) + + // Wait long enough for several heartbeats to fire. + deadline := time.After(500 * time.Millisecond) + for srv.heartbeatCount.Load() < 3 { + select { + case <-deadline: + t.Fatalf("only %d heartbeats fired in 500ms", srv.heartbeatCount.Load()) + case <-time.After(20 * time.Millisecond): + } + } + assert.True(t, c.IsActive()) + assert.Equal(t, int64(0), srv.deregisterCount.Load()) +} + +func TestPickInternalPort_InRange(t *testing.T) { + for i := 0; i < 100; i++ { + p := pickInternalPort() + assert.GreaterOrEqual(t, int(p), internalPortMin) + assert.Less(t, int(p), internalPortMax) + } +} + +func TestAPIError_StringFormat(t *testing.T) { + e := &APIError{Status: 422, Body: "could not connect to peer port"} + assert.Contains(t, e.Error(), "422") + assert.Contains(t, e.Error(), "could not connect") +} + +var _ portForwarder = (*fakeForwarder)(nil) +var _ boxService = (*fakeBoxService)(nil) diff --git a/portforward/portforward.go b/portforward/portforward.go new file mode 100644 index 00000000..8de16c14 --- /dev/null +++ b/portforward/portforward.go @@ -0,0 +1,290 @@ +// Package portforward opens TCP ports on the local network gateway via UPnP +// IGD so a peer-proxy inbound is reachable from the public internet without +// manual router configuration. IGDv2 is tried first and IGDv1 is the +// fallback. +package portforward + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "sync" + "time" + + "github.com/huin/goupnp/dcps/internetgateway1" + "github.com/huin/goupnp/dcps/internetgateway2" +) + +// ErrNoPortForwarding is returned when no UPnP gateway is reachable, the +// gateway refuses to map a port, or the discovery scan times out. Callers +// should treat this as "this network can't host a peer proxy" and surface it +// to the user rather than retry indefinitely. +var ErrNoPortForwarding = errors.New("no port forwarding available") + +type Mapping struct { + ExternalPort uint16 + InternalPort uint16 + InternalIP string + Protocol string + LeaseDuration time.Duration + Method string +} + +// igdClient is the subset of the IGDv2/v1 clients we use. goupnp's generated +// clients already satisfy this shape. +type igdClient interface { + AddPortMapping(remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error + DeletePortMapping(remoteHost string, externalPort uint16, protocol string) error + GetExternalIPAddress() (string, error) +} + +// Forwarder manages a single port mapping on the local gateway. Construct +// one per peer-proxy session. +type Forwarder struct { + mu sync.Mutex + client igdClient + method string + mapping *Mapping + cancel context.CancelFunc +} + +// NewForwarder discovers the local gateway and returns a Forwarder bound to +// it. Callers should pick a 5-10s timeout on ctx — UPnP discovery is M-SEARCH +// multicast and waits for replies. +func NewForwarder(ctx context.Context) (*Forwarder, error) { + if c, err := discoverIGDv2(ctx); err == nil && c != nil { + return &Forwarder{client: c, method: "upnp-igd2"}, nil + } + if c, err := discoverIGDv1(ctx); err == nil && c != nil { + return &Forwarder{client: c, method: "upnp-igd1"}, nil + } + return nil, ErrNoPortForwarding +} + +// MapPort asks the gateway to forward externalPort → (LocalIP():internalPort) +// for TCP. Lease duration is requested as 1 hour but some routers ignore the +// request and assign their own (or none — "permanent"). description is shown +// in the router's UI so users can identify and remove the mapping manually +// if needed. +func (f *Forwarder) MapPort(ctx context.Context, internalPort uint16, description string) (*Mapping, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.mapping != nil { + return nil, errors.New("forwarder already has an active mapping") + } + + internalIP, err := localIP() + if err != nil { + return nil, fmt.Errorf("determine local ip: %w", err) + } + + const requestedLease uint32 = 3600 + // externalPort defaults to internalPort. If the router already has that + // port mapped to someone else, AddPortMapping fails and the caller can + // retry with a different internalPort. + externalPort := internalPort + client := f.client + err = runWithCtx(ctx, func() error { + return client.AddPortMapping("", externalPort, "TCP", internalPort, internalIP, true, description, requestedLease) + }) + if err != nil { + return nil, fmt.Errorf("add port mapping: %w", err) + } + + f.mapping = &Mapping{ + ExternalPort: externalPort, + InternalPort: internalPort, + InternalIP: internalIP, + Protocol: "TCP", + LeaseDuration: time.Duration(requestedLease) * time.Second, + Method: f.method, + } + return f.mapping, nil +} + +// UnmapPort removes the active mapping. No-op if no mapping is active. +// Always called as part of teardown — even if the gateway has already let +// the lease expire, DeletePortMapping is the polite signal to the router. +func (f *Forwarder) UnmapPort(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.cancel != nil { + f.cancel() + f.cancel = nil + } + if f.mapping == nil { + return nil + } + m := f.mapping + client := f.client + f.mapping = nil + err := runWithCtx(ctx, func() error { + return client.DeletePortMapping("", m.ExternalPort, m.Protocol) + }) + if err != nil { + return fmt.Errorf("delete port mapping: %w", err) + } + return nil +} + +// StartRenewal launches a goroutine that re-issues AddPortMapping at half +// the lease duration (minimum 1 minute) until ctx is cancelled or UnmapPort +// is called. Routers that ignored the requested lease and assigned their +// own short TTL would otherwise drop the mapping mid-session. +func (f *Forwarder) StartRenewal(ctx context.Context) { + f.mu.Lock() + defer f.mu.Unlock() + if f.cancel != nil { + return + } + if f.mapping == nil { + return + } + renewCtx, cancel := context.WithCancel(ctx) + f.cancel = cancel + interval := f.mapping.LeaseDuration / 2 + if interval < 1*time.Minute { + interval = 1 * time.Minute + } + go f.renewLoop(renewCtx, interval) +} + +func (f *Forwarder) renewLoop(ctx context.Context, interval time.Duration) { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + f.mu.Lock() + m := f.mapping + client := f.client + f.mu.Unlock() + if m == nil { + return + } + // Most routers treat a re-issued AddPortMapping as "extend the + // existing lease"; some replace it with a fresh one. Either is + // fine here. + err := runWithCtx(ctx, func() error { + return client.AddPortMapping("", m.ExternalPort, "TCP", m.InternalPort, m.InternalIP, true, "Lantern peer share (renew)", uint32(m.LeaseDuration/time.Second)) + }) + if err != nil { + slog.Warn("portforward: lease renewal failed", "err", err, "external_port", m.ExternalPort) + } + } + } +} + +// ExternalIP queries the gateway for its WAN-side IP address. Cheaper than +// dialing a public-IP service when we already have a UPnP client open. +func (f *Forwarder) ExternalIP(ctx context.Context) (string, error) { + f.mu.Lock() + c := f.client + f.mu.Unlock() + var ip string + err := runWithCtx(ctx, func() error { + got, gerr := c.GetExternalIPAddress() + if gerr != nil { + return gerr + } + ip = got + return nil + }) + if err != nil { + return "", fmt.Errorf("get external ip: %w", err) + } + if ip == "" { + return "", fmt.Errorf("gateway returned empty external ip") + } + return ip, nil +} + +// localIP dials an external UDP "no-op" address and inspects the source IP +// the OS would have chosen — no packets are actually sent. +func localIP() (string, error) { + conn, err := net.Dial("udp", "8.8.8.8:53") + if err != nil { + return "", fmt.Errorf("dial udp for local ip: %w", err) + } + defer func() { _ = conn.Close() }() + addr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return "", fmt.Errorf("unexpected local addr type %T", conn.LocalAddr()) + } + return addr.IP.String(), nil +} + +func LocalIP() (string, error) { return localIP() } + +// runWithCtx wraps a blocking call so the caller's context can abort the +// wait. The wrapped goroutine still runs to completion and may leak briefly +// — UPnP/HTTP calls have their own underlying timeouts — but we no longer +// hand the entire wait time to an unresponsive gateway. +func runWithCtx(ctx context.Context, fn func() error) error { + done := make(chan error, 1) + go func() { done <- fn() }() + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} + +func discoverIGDv2(ctx context.Context) (igdClient, error) { + clients, _, err := internetgateway2.NewWANIPConnection2ClientsCtx(ctx) + if err != nil { + return nil, err + } + if len(clients) == 0 { + return nil, nil + } + return wanIPv2Wrapper{c: clients[0]}, nil +} + +func discoverIGDv1(ctx context.Context) (igdClient, error) { + clients, _, err := internetgateway1.NewWANIPConnection1ClientsCtx(ctx) + if err != nil { + return nil, err + } + if len(clients) == 0 { + return nil, nil + } + return wanIPv1Wrapper{c: clients[0]}, nil +} + +// IGDv1 and IGDv2's generated clients have slightly different method +// signatures, so wrappers normalize them to a single igdClient interface. + +type wanIPv2Wrapper struct{ c *internetgateway2.WANIPConnection2 } + +func (w wanIPv2Wrapper) AddPortMapping(remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error { + return w.c.AddPortMapping(remoteHost, externalPort, protocol, internalPort, internalClient, enabled, description, leaseDuration) +} +func (w wanIPv2Wrapper) DeletePortMapping(remoteHost string, externalPort uint16, protocol string) error { + return w.c.DeletePortMapping(remoteHost, externalPort, protocol) +} +func (w wanIPv2Wrapper) GetExternalIPAddress() (string, error) { + return w.c.GetExternalIPAddress() +} + +type wanIPv1Wrapper struct{ c *internetgateway1.WANIPConnection1 } + +func (w wanIPv1Wrapper) AddPortMapping(remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error { + return w.c.AddPortMapping(remoteHost, externalPort, protocol, internalPort, internalClient, enabled, description, leaseDuration) +} +func (w wanIPv1Wrapper) DeletePortMapping(remoteHost string, externalPort uint16, protocol string) error { + return w.c.DeletePortMapping(remoteHost, externalPort, protocol) +} +func (w wanIPv1Wrapper) GetExternalIPAddress() (string, error) { + return w.c.GetExternalIPAddress() +} + +var ( + _ igdClient = wanIPv2Wrapper{} + _ igdClient = wanIPv1Wrapper{} +) diff --git a/portforward/portforward_test.go b/portforward/portforward_test.go new file mode 100644 index 00000000..be16389a --- /dev/null +++ b/portforward/portforward_test.go @@ -0,0 +1,234 @@ +package portforward + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeIGD struct { + mu sync.Mutex + addCalls atomic.Int64 + deleteCalls atomic.Int64 + addErr error + deleteErr error + extIPErr error + extIP string + addBlock chan struct{} // if non-nil, AddPortMapping blocks on receive + lastAdd mappingArgs + lastDelete deleteArgs +} + +type mappingArgs struct { + externalPort, internalPort uint16 + internalClient, description string + leaseDuration uint32 +} + +type deleteArgs struct { + externalPort uint16 + protocol string +} + +func (f *fakeIGD) AddPortMapping(_ string, externalPort uint16, _ string, internalPort uint16, internalClient string, _ bool, description string, leaseDuration uint32) error { + f.addCalls.Add(1) + if f.addBlock != nil { + <-f.addBlock + } + f.mu.Lock() + f.lastAdd = mappingArgs{ + externalPort: externalPort, + internalPort: internalPort, + internalClient: internalClient, + description: description, + leaseDuration: leaseDuration, + } + f.mu.Unlock() + return f.addErr +} + +func (f *fakeIGD) DeletePortMapping(_ string, externalPort uint16, protocol string) error { + f.deleteCalls.Add(1) + f.mu.Lock() + f.lastDelete = deleteArgs{externalPort: externalPort, protocol: protocol} + f.mu.Unlock() + return f.deleteErr +} + +func (f *fakeIGD) GetExternalIPAddress() (string, error) { + if f.extIPErr != nil { + return "", f.extIPErr + } + if f.extIP == "" { + return "203.0.113.1", nil + } + return f.extIP, nil +} + +func newTestForwarder(t *testing.T, c *fakeIGD) *Forwarder { + t.Helper() + return &Forwarder{client: c, method: "fake"} +} + +func TestForwarder_MapPort_HappyPath(t *testing.T) { + c := &fakeIGD{} + f := newTestForwarder(t, c) + + m, err := f.MapPort(context.Background(), 30001, "test") + require.NoError(t, err) + assert.Equal(t, uint16(30001), m.ExternalPort) + assert.Equal(t, uint16(30001), m.InternalPort) + assert.Equal(t, "TCP", m.Protocol) + assert.Equal(t, "fake", m.Method) + assert.Equal(t, int64(1), c.addCalls.Load()) +} + +func TestForwarder_MapPort_DoubleMapRejected(t *testing.T) { + c := &fakeIGD{} + f := newTestForwarder(t, c) + + _, err := f.MapPort(context.Background(), 30001, "test") + require.NoError(t, err) + _, err = f.MapPort(context.Background(), 30002, "test") + assert.ErrorContains(t, err, "already has an active mapping") +} + +func TestForwarder_MapPort_PropagatesGatewayError(t *testing.T) { + c := &fakeIGD{addErr: errors.New("conflict")} + f := newTestForwarder(t, c) + + _, err := f.MapPort(context.Background(), 30001, "test") + assert.ErrorContains(t, err, "add port mapping") +} + +// MapPort must respect the caller's context — a hung router shouldn't tie up +// Start past its deadline. +func TestForwarder_MapPort_RespectsContextCancellation(t *testing.T) { + block := make(chan struct{}) + c := &fakeIGD{addBlock: block} + f := newTestForwarder(t, c) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := f.MapPort(ctx, 30001, "test") + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + close(block) // release the leaked goroutine +} + +func TestForwarder_UnmapPort_NoMappingIsNoop(t *testing.T) { + c := &fakeIGD{} + f := newTestForwarder(t, c) + + require.NoError(t, f.UnmapPort(context.Background())) + assert.Equal(t, int64(0), c.deleteCalls.Load()) +} + +func TestForwarder_UnmapPort_RemovesMapping(t *testing.T) { + c := &fakeIGD{} + f := newTestForwarder(t, c) + + _, err := f.MapPort(context.Background(), 30001, "test") + require.NoError(t, err) + + require.NoError(t, f.UnmapPort(context.Background())) + assert.Equal(t, int64(1), c.deleteCalls.Load()) + assert.Equal(t, uint16(30001), c.lastDelete.externalPort) + assert.Equal(t, "TCP", c.lastDelete.protocol) + + // Calling MapPort after UnmapPort must succeed (mapping cleared). + _, err = f.MapPort(context.Background(), 30002, "test") + require.NoError(t, err) +} + +func TestForwarder_StartRenewal_ReissuesAddPortMapping(t *testing.T) { + c := &fakeIGD{} + f := newTestForwarder(t, c) + + // Use a short lease so the renewal interval clamps to the 1m floor; we + // invoke the loop directly with a fast interval to avoid waiting. + _, err := f.MapPort(context.Background(), 30001, "test") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + go f.renewLoop(ctx, 20*time.Millisecond) + + deadline := time.After(2 * time.Second) + for c.addCalls.Load() < 3 { + select { + case <-deadline: + t.Fatalf("renewal fired only %d times", c.addCalls.Load()) + case <-time.After(10 * time.Millisecond): + } + } + cancel() +} + +// Cancelling the renewal ctx must stop the loop even with a long interval. +func TestForwarder_StartRenewal_CancelsCleanly(t *testing.T) { + c := &fakeIGD{} + f := newTestForwarder(t, c) + + _, err := f.MapPort(context.Background(), 30001, "test") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + f.renewLoop(ctx, time.Hour) + close(done) + }() + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("renewLoop did not exit after ctx cancel") + } +} + +func TestForwarder_ExternalIP(t *testing.T) { + c := &fakeIGD{extIP: "203.0.113.50"} + f := newTestForwarder(t, c) + ip, err := f.ExternalIP(context.Background()) + require.NoError(t, err) + assert.Equal(t, "203.0.113.50", ip) +} + +func TestForwarder_ExternalIP_EmptyIsError(t *testing.T) { + f := &Forwarder{client: emptyExtIPClient{}, method: "fake"} + _, err := f.ExternalIP(context.Background()) + assert.ErrorContains(t, err, "empty external ip") +} + +type emptyExtIPClient struct{} + +func (emptyExtIPClient) AddPortMapping(string, uint16, string, uint16, string, bool, string, uint32) error { + return nil +} +func (emptyExtIPClient) DeletePortMapping(string, uint16, string) error { return nil } +func (emptyExtIPClient) GetExternalIPAddress() (string, error) { return "", nil } + +func TestForwarder_ExternalIP_PropagatesError(t *testing.T) { + c := &fakeIGD{extIPErr: errors.New("upstream timeout")} + f := newTestForwarder(t, c) + _, err := f.ExternalIP(context.Background()) + assert.ErrorContains(t, err, "upstream timeout") +} + +func TestLocalIP(t *testing.T) { + // Best-effort: localIP needs working UDP. CI machines have it; offline + // dev machines may not. Skip rather than fail if it errors. + ip, err := LocalIP() + if err != nil { + t.Skipf("localIP unavailable in this environment: %v", err) + } + assert.NotEmpty(t, ip) +} From 060a53b9013a8161be5f241619f74d8c12246255 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 08:57:36 -0600 Subject: [PATCH 02/63] review: fix 5 correctness issues in peer + portforward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five Copilot comments on #458 flagged real lifecycle / concurrency bugs in the original PR 1 implementation. 1) peer.go:103 — Start checked c.active under the lock then released it before doing setup. Two concurrent Starts could both pass the check, both run MapPort/Register/box.Start, and the second's state would overwrite the first's, orphaning a registered route + open box that this Client could no longer Stop. Added a starting flag that's set under the lock alongside the active check, so any second Start while the first is in flight is rejected. 2) peer.go:127 / 145 — Rollback after MapPort, ExternalIP, Register, BuildBoxService, or box.Start failures all reused the caller's ctx. If the caller's ctx had already timed out or been cancelled, the Deregister and UnmapPort calls in the rollback would also abort immediately, leaking the registered route + router rule. Replaced the inline rollbacks with a single defer that runs against a fresh peerCleanupTimeout-bounded Background context, so cleanup always gets a live deadline. 3) portforward.go:234 — runWithCtx started fn even when the caller's ctx was already canceled, only stopping the wait. The goroutine would still run AddPortMapping or DeletePortMapping in the background, creating side effects after the caller had given up. Added a ctx.Err() check at the top so an already-canceled ctx returns immediately without spawning the goroutine. 4) portforward.go:128 — UnmapPort cleared f.mapping before DeletePortMapping succeeded. A failed delete (gateway momentarily unavailable, ctx expired, etc.) would leave the Forwarder "forgetting" about a router rule that was actually still live, so the caller couldn't retry the unmap and the user would have to wait for the UPnP lease to expire. Moved the f.mapping = nil to after the delete returns nil. Test coverage: * New TestClient_Start_ConcurrentStartsAreSerialized exercises the race fixed by issue 1: spawn two Starts, gate the first inside MapPort, release the second to observe the rejection, assert exactly one succeeds and exactly one returns "already active". * Existing tests for the rollback paths (PortForward / ExternalIP / Register / BoxStart failures) still pass — the cleanup defer takes the same shape as before but now uses a fresh ctx. go test -race ./peer/... ./portforward/... and golangci-lint --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 66 +++++++++++++++++++++++++++++++------- peer/peer_test.go | 63 ++++++++++++++++++++++++++++++++++++ portforward/portforward.go | 19 ++++++++--- 3 files changed, 132 insertions(+), 16 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index c5668054..708a6409 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -63,7 +63,14 @@ type Config struct { type Client struct { cfg Config - mu sync.Mutex + mu sync.Mutex + // starting and active together serialize Start: starting is true while a + // Start call is in flight, active is true once it succeeds. Without + // starting, two concurrent Start calls could both pass the !active check + // and run setup in parallel — the second's state would overwrite the + // first's, orphaning a registered route + open box that this Client can + // no longer Stop. + starting bool active bool status Status cancelRun context.CancelFunc @@ -73,6 +80,12 @@ type Client struct { routeID string } +// peerCleanupTimeout caps how long Start's rollback path waits for +// Deregister / UnmapPort. Cleanup uses a fresh Background context (not the +// caller's ctx) so an already-canceled or expired Start ctx doesn't skip +// teardown and leak the registered route or router rule. +const peerCleanupTimeout = 30 * time.Second + func NewClient(cfg Config) (*Client, error) { if cfg.API == nil { return nil, errors.New("peer: Config.API is required") @@ -96,12 +109,47 @@ func NewClient(cfg Config) (*Client, error) { // returning. func (c *Client) Start(ctx context.Context) error { c.mu.Lock() - if c.active { + if c.active || c.starting { c.mu.Unlock() return errors.New("peer client already active") } + c.starting = true c.mu.Unlock() + var ( + success bool + fwd portForwarder + regResp *RegisterResponse + box boxService + runCtx context.Context + cancelRun context.CancelFunc + ) + defer func() { + c.mu.Lock() + c.starting = false + c.mu.Unlock() + if success { + return + } + // A fresh ctx — the caller's may already be canceled by the time we + // roll back, which would skip Deregister and UnmapPort and leak the + // registered route + router rule. + cleanupCtx, cancel := context.WithTimeout(context.Background(), peerCleanupTimeout) + defer cancel() + if box != nil { + _ = box.Close() + } + if cancelRun != nil { + cancelRun() + } + if regResp != nil { + _ = c.cfg.API.Deregister(cleanupCtx, regResp.RouteID) + } + if fwd != nil { + _ = fwd.UnmapPort(cleanupCtx) + } + }() + fwd, err := c.cfg.NewForwarder(ctx) if err != nil { return fmt.Errorf("discover gateway: %w", err) @@ -114,35 +162,28 @@ func (c *Client) Start(ctx context.Context) error { externalIP, err := fwd.ExternalIP(ctx) if err != nil { - _ = fwd.UnmapPort(ctx) return fmt.Errorf("get external ip: %w", err) } - regResp, err := c.cfg.API.Register(ctx, RegisterRequest{ + regResp, err = c.cfg.API.Register(ctx, RegisterRequest{ ExternalIP: externalIP, ExternalPort: mapping.ExternalPort, InternalPort: mapping.InternalPort, }) if err != nil { - _ = fwd.UnmapPort(ctx) return fmt.Errorf("register with lantern-cloud: %w", err) } // runCtx must outlive Start, so it derives from Background() rather than // the caller's ctx — otherwise libbox's stored ctx would die when Start // returns and take the box's internal goroutines with it. - runCtx, cancelRun := context.WithCancel(context.Background()) - box, err := c.cfg.BuildBoxService(runCtx, regResp.ServerConfig) + runCtx, cancelRun = context.WithCancel(context.Background()) + box, err = c.cfg.BuildBoxService(runCtx, regResp.ServerConfig) if err != nil { cancelRun() - _ = c.cfg.API.Deregister(ctx, regResp.RouteID) - _ = fwd.UnmapPort(ctx) return fmt.Errorf("build sing-box: %w", err) } if err := box.Start(); err != nil { cancelRun() - _ = box.Close() - _ = c.cfg.API.Deregister(ctx, regResp.RouteID) - _ = fwd.UnmapPort(ctx) return fmt.Errorf("start sing-box: %w", err) } @@ -182,6 +223,7 @@ func (c *Client) Start(ctx context.Context) error { "route_id", regResp.RouteID, "heartbeat", heartbeat, ) + success = true return nil } diff --git a/peer/peer_test.go b/peer/peer_test.go index 8addf5f6..8f35c1b1 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -86,6 +86,32 @@ func (f *fakeForwarder) wasMapped() bool { return f.mapped } +// slowMapForwarder blocks MapPort on a gate channel and signals via entered +// when the call is in flight. Used to race two concurrent Starts so the +// test can observe the serialization invariant. +type slowMapForwarder struct { + gate chan struct{} + entered chan struct{} +} + +func (f *slowMapForwarder) MapPort(_ context.Context, internalPort uint16, _ string) (*portforward.Mapping, error) { + select { + case f.entered <- struct{}{}: + default: + } + <-f.gate + return &portforward.Mapping{ + ExternalPort: internalPort, InternalPort: internalPort, + InternalIP: "192.168.1.10", Protocol: "TCP", + LeaseDuration: time.Hour, Method: "fake", + }, nil +} +func (f *slowMapForwarder) UnmapPort(context.Context) error { return nil } +func (f *slowMapForwarder) StartRenewal(context.Context) {} +func (f *slowMapForwarder) ExternalIP(context.Context) (string, error) { + return "203.0.113.99", nil +} + type fakeBoxService struct { startErr error closeErr error @@ -235,6 +261,43 @@ func TestClient_Start_DoubleStartIsError(t *testing.T) { assert.ErrorContains(t, err, "already active") } +// Two goroutines hitting Start at the same time must not both run setup — +// the second one would overwrite the first's state, leaving the first +// session orphaned with no way to Stop it through this Client. +func TestClient_Start_ConcurrentStartsAreSerialized(t *testing.T) { + fwd := &slowMapForwarder{ + gate: make(chan struct{}), + entered: make(chan struct{}, 1), + } + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + t.Cleanup(func() { _ = c.Stop(context.Background()) }) + + results := make(chan error, 2) + for range 2 { + go func() { results <- c.Start(context.Background()) }() + } + // Wait for one Start to be inside MapPort holding starting=true; release + // it once the second Start has had a chance to observe the contended + // state and reject. + <-fwd.entered + close(fwd.gate) + + var nilCount, errCount int + for range 2 { + if err := <-results; err == nil { + nilCount++ + } else { + errCount++ + assert.ErrorContains(t, err, "already active") + } + } + assert.Equal(t, 1, nilCount, "exactly one Start must succeed") + assert.Equal(t, 1, errCount, "the racing Start must be rejected") + assert.Equal(t, int64(1), srv.registerCount.Load()) +} + func TestClient_Start_PortForwardFailureUnwinds(t *testing.T) { fwd := &fakeForwarder{mapErr: portforward.ErrNoPortForwarding} box := &fakeBoxService{} diff --git a/portforward/portforward.go b/portforward/portforward.go index 8de16c14..9f77b357 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -107,6 +107,11 @@ func (f *Forwarder) MapPort(ctx context.Context, internalPort uint16, descriptio // UnmapPort removes the active mapping. No-op if no mapping is active. // Always called as part of teardown — even if the gateway has already let // the lease expire, DeletePortMapping is the polite signal to the router. +// +// f.mapping is cleared only on a successful delete. A failed delete leaves +// the mapping in place so the caller can retry; otherwise we'd "forget" +// about a router rule that's actually still live and the user would have +// to wait for the UPnP lease to expire. func (f *Forwarder) UnmapPort(ctx context.Context) error { f.mu.Lock() defer f.mu.Unlock() @@ -119,13 +124,13 @@ func (f *Forwarder) UnmapPort(ctx context.Context) error { } m := f.mapping client := f.client - f.mapping = nil err := runWithCtx(ctx, func() error { return client.DeletePortMapping("", m.ExternalPort, m.Protocol) }) if err != nil { return fmt.Errorf("delete port mapping: %w", err) } + f.mapping = nil return nil } @@ -221,10 +226,16 @@ func localIP() (string, error) { func LocalIP() (string, error) { return localIP() } // runWithCtx wraps a blocking call so the caller's context can abort the -// wait. The wrapped goroutine still runs to completion and may leak briefly -// — UPnP/HTTP calls have their own underlying timeouts — but we no longer -// hand the entire wait time to an unresponsive gateway. +// wait. Returns ctx.Err() immediately if ctx is already cancelled, so the +// gateway-side side effect (port mapping, etc.) doesn't fire after the +// caller has already given up. If ctx cancels mid-call, the wrapped +// goroutine still runs to completion — UPnP/HTTP calls have their own +// underlying timeouts — but we no longer hand the entire wait time to an +// unresponsive gateway. func runWithCtx(ctx context.Context, fn func() error) error { + if err := ctx.Err(); err != nil { + return err + } done := make(chan error, 1) go func() { done <- fn() }() select { From a15ee10d74371f81cf09eb63291c80aedfed7be4 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 10:25:27 -0600 Subject: [PATCH 03/63] peer: forward X-Lantern-Feature-Override on api requests config/fetcher.go forwards FeatureOverridesKey (RADIANCE_FEATURE_OVERRIDES) as X-Lantern-Feature-Override on /config-new requests so QA can flip features on ahead of public rollout. peer.API.do only sent X-Lantern-Device-Id, so even with the override set the server-side gate rejected the peer register/heartbeat/deregister endpoints. Forward the same header. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/api.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/peer/api.go b/peer/api.go index 45d298c5..05752402 100644 --- a/peer/api.go +++ b/peer/api.go @@ -9,6 +9,8 @@ import ( "fmt" "io" "net/http" + + "github.com/getlantern/radiance/common/settings" ) type RegisterRequest struct { @@ -93,6 +95,14 @@ func (a *API) do(ctx context.Context, method, path string, body, out any) error r.Header.Set("Content-Type", "application/json") } r.Header.Set("X-Lantern-Device-Id", a.deviceID) + // Forward the same feature-override header that config/fetcher.go uses + // for /config-new requests, so QA can flip on `peer_proxy` ahead of the + // public-flag rollout via FeatureOverridesKey (RADIANCE_FEATURE_OVERRIDES). + // Without this the server-side gate rejects register/heartbeat/deregister + // regardless of the local toggle. + if val := settings.GetString(settings.FeatureOverridesKey); val != "" { + r.Header.Set("X-Lantern-Feature-Override", val) + } resp, err := a.httpClient.Do(r) if err != nil { From 8d3089d36b78e3538e0ccb2a0e78bf5cc65beaf6 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 16:56:26 -0600 Subject: [PATCH 04/63] peer: bypass user's own VPN TUN for proxied traffic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the peer-share user has Lantern VPN running, its sing-box installs a TUN (utun225) with auto_route=true that captures all outbound traffic on the host. Without intervention, the peer's sing-box (a separate libbox instance) would dial destination addresses through the OS routing table — which now points at utun225 — so the censored client's traffic would egress through the local user's Lantern proxy instead of their residential connection. That defeats the whole point of peer-sharing (use the user's home IP as a circumvention exit) and double-bills bandwidth through Lantern infra. Splice route.auto_detect_interface=true into the server-supplied sing-box options before handing them to libbox.NewServiceWithContext. sing-box's interface monitor picks the underlying physical iface (en0/wlan0) rather than any TUN, and binds outbound dials directly to it — bypassing the VPN TUN entirely. The bypass is applied client-side rather than server-side because it's a property of the client's environment (whether the user has a TUN VPN running), not the proxy track config. Setting it unconditionally is safe — when no TUN is present, auto_detect just picks the same default interface the OS would have chosen anyway. Tests cover the three branches: no route block in the input, an existing route block (other fields preserved), and malformed JSON. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 45 ++++++++++++++++++++++++++++++++++++++++++++- peer/peer_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/peer/peer.go b/peer/peer.go index 708a6409..aada8ebe 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -2,6 +2,7 @@ package peer import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -173,11 +174,23 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("register with lantern-cloud: %w", err) } + // The peer's outbound traffic must bypass any TUN device the user's own + // VPN may have installed — otherwise censored clients' traffic would + // egress through the local user's Lantern proxy instead of their + // residential connection, defeating the whole point of peer-sharing. + // auto_detect_interface tells sing-box to bind outbound dials to the + // underlying physical interface rather than whatever the OS routing + // table picks (which would be the VPN TUN if the VPN is up). + options, err := ensurePeerOutboundsBypassVPN(regResp.ServerConfig) + if err != nil { + return fmt.Errorf("patch sing-box options: %w", err) + } + // runCtx must outlive Start, so it derives from Background() rather than // the caller's ctx — otherwise libbox's stored ctx would die when Start // returns and take the box's internal goroutines with it. runCtx, cancelRun = context.WithCancel(context.Background()) - box, err = c.cfg.BuildBoxService(runCtx, regResp.ServerConfig) + box, err = c.cfg.BuildBoxService(runCtx, options) if err != nil { cancelRun() return fmt.Errorf("build sing-box: %w", err) @@ -336,6 +349,36 @@ func isNotRegistered(err error) bool { return errors.As(err, &apiErr) && apiErr.Status == 404 } +// ensurePeerOutboundsBypassVPN guarantees the peer sing-box's outbound dials +// bind to the physical interface rather than whatever the OS routing table +// picks. Without this, when the user's own Lantern VPN is up its TUN holds +// the default route and the peer's outbound traffic — i.e. the censored +// client's destination requests — would egress through Lantern's proxy +// network instead of the user's residential connection. That defeats the +// whole point of using the user's home IP as a circumvention exit. +// +// We splice the flag into whatever sing-box options the server supplied +// rather than relying on the server-side track config to set it, since the +// VPN-bypass requirement is a property of the *client's* environment, not +// the proxy track config. +func ensurePeerOutboundsBypassVPN(options string) (string, error) { + var raw map[string]any + if err := json.Unmarshal([]byte(options), &raw); err != nil { + return "", fmt.Errorf("decode options: %w", err) + } + route, _ := raw["route"].(map[string]any) + if route == nil { + route = map[string]any{} + raw["route"] = route + } + route["auto_detect_interface"] = true + out, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("encode options: %w", err) + } + return string(out), nil +} + func pickInternalPort() uint16 { return uint16(internalPortMin + rand.IntN(internalPortMax-internalPortMin)) } diff --git a/peer/peer_test.go b/peer/peer_test.go index 8f35c1b1..74a2ad27 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -463,6 +463,37 @@ func TestClient_Heartbeat_TransientErrorDoesNotStop(t *testing.T) { assert.Equal(t, int64(0), srv.deregisterCount.Load()) } +// The peer's sing-box must bypass the user's own VPN TUN — verify both the +// "no route block at all" and "existing route block" cases get the flag set, +// and that other route-level keys are preserved. +func TestEnsurePeerOutboundsBypassVPN(t *testing.T) { + t.Run("adds route block when missing", func(t *testing.T) { + in := `{"inbounds":[{"type":"samizdat","tag":"samizdat-in"}]}` + out, err := ensurePeerOutboundsBypassVPN(in) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + route := parsed["route"].(map[string]any) + assert.Equal(t, true, route["auto_detect_interface"]) + assert.Contains(t, parsed, "inbounds", "must preserve other top-level fields") + }) + t.Run("preserves existing route fields", func(t *testing.T) { + in := `{"route":{"rules":[{"action":"sniff"}],"final":"direct"}}` + out, err := ensurePeerOutboundsBypassVPN(in) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + route := parsed["route"].(map[string]any) + assert.Equal(t, true, route["auto_detect_interface"]) + assert.Equal(t, "direct", route["final"]) + assert.NotEmpty(t, route["rules"]) + }) + t.Run("rejects malformed json", func(t *testing.T) { + _, err := ensurePeerOutboundsBypassVPN(`{not json`) + assert.Error(t, err) + }) +} + func TestPickInternalPort_InRange(t *testing.T) { for i := 0; i < 100; i++ { p := pickInternalPort() From b19243bae92a0444d645655b4cf46ef7a660e84a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 13:41:39 -0600 Subject: [PATCH 05/63] peer/portforward: address Copilot review (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from https://github.com/getlantern/radiance/pull/458#discussion: 1. portforward.NewForwarder: when ctx is canceled/expired during discovery, propagate the ctx error instead of masking it as ErrNoPortForwarding. Callers can now distinguish 'this network can't host a peer' from 'we ran out of time, retry later'. 2. portforward.MapPort: when the gateway refuses a mapping (non-ctx error), wrap with errors.Join(ErrNoPortForwarding, err) so callers can detect the documented case via errors.Is while keeping the underlying router- specific error for diagnostics. 3. portforward.localIP: fall back to enumerating interfaces if the net.Dial("udp", "8.8.8.8:53") trick fails. Covers IPv6-only hosts and networks that block outbound to 8.8.8.8. 4. portforward.discoverIGDv1: also probe WANPPPConnection (PPPoE/DSL routers), not just WANIPConnection. Many consumer DSL CPEs only expose UPnP via WANPPPConnection. 5. peer.Stop: wait for any in-flight Start to finish before checking active. Without this, a Stop arriving while starting=true returns nil and the racing Start leaves the client active afterward — the exact orphaned-session shape Start's rollback path is designed to prevent. Wait honors ctx so a cancellable caller still has an exit door. Tests added: - TestForwarder_MapPort_GatewayErrorWrapsErrNoPortForwarding - TestLocalIPByInterfaceScan - TestClient_Stop_WaitsForInflightStart - TestClient_Stop_RespectsCtxWhileWaitingForStart All pre-existing tests pass under -race. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 26 ++++++++++ peer/peer_test.go | 67 +++++++++++++++++++++++++ portforward/portforward.go | 88 +++++++++++++++++++++++++++++++-- portforward/portforward_test.go | 25 ++++++++++ 4 files changed, 201 insertions(+), 5 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index aada8ebe..ea37c666 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -65,6 +65,11 @@ type Client struct { cfg Config mu sync.Mutex + // startingDone is created when Start sets starting=true and closed when + // the same Start clears it (success or fail). Stop callers that arrive + // mid-Start block on this channel rather than racing the in-flight + // setup. Nil whenever no Start is in flight. + startingDone chan struct{} // starting and active together serialize Start: starting is true while a // Start call is in flight, active is true once it succeeds. Without // starting, two concurrent Start calls could both pass the !active check @@ -115,6 +120,7 @@ func (c *Client) Start(ctx context.Context) error { return errors.New("peer client already active") } c.starting = true + c.startingDone = make(chan struct{}) c.mu.Unlock() var ( @@ -128,7 +134,10 @@ func (c *Client) Start(ctx context.Context) error { defer func() { c.mu.Lock() c.starting = false + done := c.startingDone + c.startingDone = nil c.mu.Unlock() + close(done) // unblocks any Stop call that arrived mid-Start if success { return } @@ -242,8 +251,25 @@ func (c *Client) Start(ctx context.Context) error { // Stop tears down an active session. Idempotent. Blocks until the heartbeat // goroutine has exited and all teardown calls have completed (or timed out). +// +// If a Start is in flight when Stop is called, Stop waits for that Start to +// finish (success or fail) before proceeding. Without this, a Stop arriving +// while starting=true would return nil and let the racing Start leave the +// client active afterward — exactly the orphaned-session shape Start's own +// rollback path is designed to prevent. The wait honors ctx so a cancellable +// caller still has an exit door if Start hangs. func (c *Client) Stop(ctx context.Context) error { c.mu.Lock() + for c.starting { + done := c.startingDone + c.mu.Unlock() + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + } + c.mu.Lock() + } if !c.active { c.mu.Unlock() return nil diff --git a/peer/peer_test.go b/peer/peer_test.go index 74a2ad27..5872419b 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -298,6 +298,73 @@ func TestClient_Start_ConcurrentStartsAreSerialized(t *testing.T) { assert.Equal(t, int64(1), srv.registerCount.Load()) } +// A Stop that arrives while Start is still in flight must wait for that +// Start to finish — otherwise it returns nil and the racing Start happily +// leaves the client active afterward, which produces the exact orphaned- +// session shape Start's own rollback path is designed to prevent. +func TestClient_Stop_WaitsForInflightStart(t *testing.T) { + fwd := &slowMapForwarder{ + gate: make(chan struct{}), + entered: make(chan struct{}, 1), + } + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + startErr := make(chan error, 1) + go func() { startErr <- c.Start(context.Background()) }() + + // Wait until Start is blocked inside MapPort (starting=true, active=false). + <-fwd.entered + + stopErr := make(chan error, 1) + go func() { stopErr <- c.Stop(context.Background()) }() + + // Stop must not return while Start is still in flight. + select { + case <-stopErr: + t.Fatal("Stop returned before Start finished — would orphan the session") + case <-time.After(50 * time.Millisecond): + } + + // Let Start complete. Stop should unblock and tear down what Start set up. + close(fwd.gate) + + require.NoError(t, <-startErr) + require.NoError(t, <-stopErr) + + // Client must be in clean post-Stop state — not active and ready to be + // Started again. + assert.False(t, c.IsActive()) + assert.Equal(t, int64(1), srv.registerCount.Load(), "Start completed once") + assert.Equal(t, int64(1), srv.deregisterCount.Load(), "Stop tore down what Start set up") +} + +// A Stop with an already-canceled ctx that races a slow Start should give +// up promptly rather than wait forever. +func TestClient_Stop_RespectsCtxWhileWaitingForStart(t *testing.T) { + fwd := &slowMapForwarder{ + gate: make(chan struct{}), + entered: make(chan struct{}, 1), + } + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + t.Cleanup(func() { + close(fwd.gate) + // Drain the in-flight Start so the test goroutines don't leak. + _ = c.Stop(context.Background()) + }) + + go func() { _ = c.Start(context.Background()) }() + <-fwd.entered + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := c.Stop(ctx) + assert.ErrorIs(t, err, context.Canceled) +} + func TestClient_Start_PortForwardFailureUnwinds(t *testing.T) { fwd := &fakeForwarder{mapErr: portforward.ErrNoPortForwarding} box := &fakeBoxService{} diff --git a/portforward/portforward.go b/portforward/portforward.go index 9f77b357..fdc0e34c 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -53,6 +53,11 @@ type Forwarder struct { // NewForwarder discovers the local gateway and returns a Forwarder bound to // it. Callers should pick a 5-10s timeout on ctx — UPnP discovery is M-SEARCH // multicast and waits for replies. +// +// Returns ErrNoPortForwarding only when discovery completes without finding +// a usable gateway. If ctx was canceled or its deadline expired during +// discovery, the ctx error is returned verbatim so callers can distinguish +// "this network can't host a peer" from "we ran out of time, retry later". func NewForwarder(ctx context.Context) (*Forwarder, error) { if c, err := discoverIGDv2(ctx); err == nil && c != nil { return &Forwarder{client: c, method: "upnp-igd2"}, nil @@ -60,6 +65,9 @@ func NewForwarder(ctx context.Context) (*Forwarder, error) { if c, err := discoverIGDv1(ctx); err == nil && c != nil { return &Forwarder{client: c, method: "upnp-igd1"}, nil } + if err := ctx.Err(); err != nil { + return nil, err + } return nil, ErrNoPortForwarding } @@ -90,7 +98,16 @@ func (f *Forwarder) MapPort(ctx context.Context, internalPort uint16, descriptio return client.AddPortMapping("", externalPort, "TCP", internalPort, internalIP, true, description, requestedLease) }) if err != nil { - return nil, fmt.Errorf("add port mapping: %w", err) + // Propagate ctx cancellation/deadline verbatim so callers can retry + // rather than treating it as a permanent "this network won't work". + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("add port mapping: %w", ctxErr) + } + // Per the ErrNoPortForwarding docstring, a gateway refusing to map a + // port is the "this network can't host a peer" case. Join the + // sentinel so callers can detect it via errors.Is while still + // surfacing the underlying router-specific reason for diagnostics. + return nil, fmt.Errorf("add port mapping: %w", errors.Join(ErrNoPortForwarding, err)) } f.mapping = &Mapping{ @@ -208,9 +225,22 @@ func (f *Forwarder) ExternalIP(ctx context.Context) (string, error) { return ip, nil } -// localIP dials an external UDP "no-op" address and inspects the source IP -// the OS would have chosen — no packets are actually sent. +// localIP returns the LAN address the OS would use to reach the gateway. +// +// First tries the UDP-noop trick (let the kernel pick a route to a known +// public address) — fastest and most accurate when the host has a working +// default route. Falls back to scanning interfaces for a private IPv4 if +// that fails, which covers networks that block 8.8.8.8 outbound or use +// non-default IPv4 routing tables. UPnP IGD itself is IPv4 in IGDv1 and +// almost always IPv4 in IGDv2, so we only consider IPv4 addresses. func localIP() (string, error) { + if ip, err := localIPByDial(); err == nil { + return ip, nil + } + return localIPByInterfaceScan() +} + +func localIPByDial() (string, error) { conn, err := net.Dial("udp", "8.8.8.8:53") if err != nil { return "", fmt.Errorf("dial udp for local ip: %w", err) @@ -223,6 +253,34 @@ func localIP() (string, error) { return addr.IP.String(), nil } +func localIPByInterfaceScan() (string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return "", fmt.Errorf("list interfaces: %w", err) + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok || ipnet.IP.IsLoopback() || ipnet.IP.IsLinkLocalUnicast() { + continue + } + ip4 := ipnet.IP.To4() + if ip4 == nil || !ip4.IsPrivate() { + continue + } + return ip4.String(), nil + } + } + return "", fmt.Errorf("no usable private ipv4 found on any interface") +} + func LocalIP() (string, error) { return localIP() } // runWithCtx wraps a blocking call so the caller's context can abort the @@ -257,15 +315,22 @@ func discoverIGDv2(ctx context.Context) (igdClient, error) { return wanIPv2Wrapper{c: clients[0]}, nil } +// discoverIGDv1 looks for both WANIPConnection and WANPPPConnection gateways. +// Cable/fiber CPE routers typically expose UPnP via WANIPConnection; DSL and +// other PPPoE-terminated CPEs typically expose it via WANPPPConnection. +// Probing only one would miss large swaths of consumer hardware. func discoverIGDv1(ctx context.Context) (igdClient, error) { - clients, _, err := internetgateway1.NewWANIPConnection1ClientsCtx(ctx) + if clients, _, err := internetgateway1.NewWANIPConnection1ClientsCtx(ctx); err == nil && len(clients) > 0 { + return wanIPv1Wrapper{c: clients[0]}, nil + } + clients, _, err := internetgateway1.NewWANPPPConnection1ClientsCtx(ctx) if err != nil { return nil, err } if len(clients) == 0 { return nil, nil } - return wanIPv1Wrapper{c: clients[0]}, nil + return wanPPPv1Wrapper{c: clients[0]}, nil } // IGDv1 and IGDv2's generated clients have slightly different method @@ -295,7 +360,20 @@ func (w wanIPv1Wrapper) GetExternalIPAddress() (string, error) { return w.c.GetExternalIPAddress() } +type wanPPPv1Wrapper struct{ c *internetgateway1.WANPPPConnection1 } + +func (w wanPPPv1Wrapper) AddPortMapping(remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error { + return w.c.AddPortMapping(remoteHost, externalPort, protocol, internalPort, internalClient, enabled, description, leaseDuration) +} +func (w wanPPPv1Wrapper) DeletePortMapping(remoteHost string, externalPort uint16, protocol string) error { + return w.c.DeletePortMapping(remoteHost, externalPort, protocol) +} +func (w wanPPPv1Wrapper) GetExternalIPAddress() (string, error) { + return w.c.GetExternalIPAddress() +} + var ( _ igdClient = wanIPv2Wrapper{} _ igdClient = wanIPv1Wrapper{} + _ igdClient = wanPPPv1Wrapper{} ) diff --git a/portforward/portforward_test.go b/portforward/portforward_test.go index be16389a..7d6e0ee2 100644 --- a/portforward/portforward_test.go +++ b/portforward/portforward_test.go @@ -232,3 +232,28 @@ func TestLocalIP(t *testing.T) { } assert.NotEmpty(t, ip) } + +// The interface-scan fallback covers networks where the UDP-noop trick +// fails (IPv6-only host, kernel rejects 8.8.8.8, etc.). Skip if the dev +// machine genuinely lacks a private IPv4 — running this on a CI worker +// without a LAN address shouldn't fail the build. +func TestLocalIPByInterfaceScan(t *testing.T) { + ip, err := localIPByInterfaceScan() + if err != nil { + t.Skipf("no private ipv4 interface available: %v", err) + } + assert.NotEmpty(t, ip) +} + +// MapPort's gateway-refused path must surface ErrNoPortForwarding via +// errors.Is so callers can distinguish "this network won't work" from +// "something else broke", per the package-level docstring. +func TestForwarder_MapPort_GatewayErrorWrapsErrNoPortForwarding(t *testing.T) { + c := &fakeIGD{addErr: errors.New("ConflictInMappingEntry")} + f := newTestForwarder(t, c) + + _, err := f.MapPort(context.Background(), 30001, "test") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoPortForwarding, "callers must be able to detect via errors.Is") + assert.ErrorContains(t, err, "ConflictInMappingEntry", "underlying gateway error must survive for diagnostics") +} From 985a97d0c1bd4a3d713f775dfcbf2c25fb0d5c43 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 14:42:10 -0600 Subject: [PATCH 06/63] peer/portforward: address Copilot review (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three honest-comment / honest-behavior fixes from https://github.com/getlantern/radiance/pull/458#discussion : 1. peer.go: heartbeat interval. The previous clamp bumped any server- supplied value below 1 minute up to 5 minutes, which would defeat the server's intent if it deliberately picked a short interval to reap stale registrations faster. Now: honor any positive value verbatim, only fall back to 5m when the field is non-positive (unset / older server / JSON omitted). 2. portforward.go: rewrote the StartRenewal comment. It used to claim the goroutine 'prevents routers from dropping the mapping when they silently assign a shorter TTL', but the renewal cadence is keyed off the *requested* lease (the only value we know) — UPnP IGD has no API to query the router-assigned lease. A router that silently shortens the TTL can still drop the mapping; the peer heartbeat path catches that and auto-Stops. The comment now describes what actually happens. 3. peer.go: rewrote the port-range comment. The old wording claimed '30000–50000 avoids well-known/registered ports and the OS ephemeral range' — but 30000–50000 overlaps both the IANA registered range (1024–49151) AND the Linux ephemeral range (default starts at 32768). The new comment is honest about that: the range minimizes collisions on the typical home network but doesn't guarantee zero, and the AddPortMapping conflict path is the safety net. No behavior change in #2 or #3 — only #1 actually changes runtime behavior, and only for short-interval server responses. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 19 +++++++++++++++---- portforward/portforward.go | 11 ++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index ea37c666..a70ad7ca 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -15,9 +15,14 @@ import ( "github.com/getlantern/radiance/portforward" ) -// Lower bound avoids well-known/registered ports; upper bound stays below the -// typical OS ephemeral range so the OS isn't likely to assign the same port -// to another local process. +// Port range chosen to minimize collision risk on the typical home network, +// not to guarantee one. 30000–50000 sits above the well-known/system range +// (0–1023) and above the ports most services use by default (web/dev/dbs +// usually <30000). It overlaps both the IANA registered range (1024–49151) +// and the OS ephemeral range on some platforms (Linux's default +// net.ipv4.ip_local_port_range starts at 32768, Windows uses 49152+), so +// a collision is still possible. AddPortMapping surfaces the conflict and +// the peer.Client caller can retry with a fresh pick. const ( internalPortMin = 30000 internalPortMax = 50000 @@ -209,10 +214,16 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("start sing-box: %w", err) } + // HeartbeatIntervalSeconds is server-driven so lantern-cloud can dial up + // the cadence on registrations it wants to expire faster. Honor any + // positive value verbatim — clamping short intervals up would defeat + // that and risk the server reaping the route between our heartbeats. + // A non-positive value means the field was unset (e.g., older server, + // JSON omitted); fall back to a sane default. heartbeat := c.cfg.HeartbeatInterval if heartbeat == 0 { heartbeat = time.Duration(regResp.HeartbeatIntervalSeconds) * time.Second - if heartbeat < time.Minute { + if heartbeat <= 0 { heartbeat = 5 * time.Minute } } diff --git a/portforward/portforward.go b/portforward/portforward.go index fdc0e34c..6a8fe9c1 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -152,9 +152,14 @@ func (f *Forwarder) UnmapPort(ctx context.Context) error { } // StartRenewal launches a goroutine that re-issues AddPortMapping at half -// the lease duration (minimum 1 minute) until ctx is cancelled or UnmapPort -// is called. Routers that ignored the requested lease and assigned their -// own short TTL would otherwise drop the mapping mid-session. +// the requested lease duration (minimum 1 minute) until ctx is cancelled +// or UnmapPort is called. The cadence is keyed off what we *requested* +// (mapping.LeaseDuration) — UPnP IGD has no API to query what the router +// actually assigned, so a router that ignored the request and silently +// applied a shorter TTL can still drop the mapping between renewal ticks. +// The peer's heartbeat path will surface that failure and auto-Stop the +// session; routine 30-minute refresh of an hour-long requested lease +// handles the common case where the router honors the requested duration. func (f *Forwarder) StartRenewal(ctx context.Context) { f.mu.Lock() defer f.mu.Unlock() From 9efe9c63f44070ab35a7bbfd74f7d0bd245e3dd9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 4 May 2026 16:23:41 -0600 Subject: [PATCH 07/63] Wire peer.Client into LocalBackend (Share My Connection PR 2/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of 4 stacked on PR 1 (peer module + portforward, #458). * common/settings: add PeerShareEnabledKey bool. * peer.Client: emit StatusEvent on Start success and Stop completion so subscribers (the new IPC SSE handler) can drive UI without polling. * backend.LocalBackend: own a peerController (interface seam over peer.Client) constructed in NewLocalBackend with kindling's HTTP client + the lantern-cloud base URL + the device ID. * PatchSettings dispatch: PeerShareEnabledKey changes route to applyPeerShare(enabled). Toggle calls are serialized by peerToggleMu so a fast off→on→off can't see the second call's "already active" rollback racing the third call's Stop. Start runs against a 30s deadline so a slow router can't block the IPC response indefinitely. On Start failure the persisted setting is rolled back so reads of PeerShareEnabledKey reflect runtime state and the Dart toggle can surface the error. * Auto-resume: if PeerShareEnabledKey is true at LocalBackend.Start(), kick off Start in a goroutine tracked by peerWG. Close() waits for peerWG before tearing down ctx, so an in-flight resume can't leave a registered route + open box behind on shutdown. * Close: if peerClient.IsActive() after the WG settles, Stop with a fresh context so Deregister has a live HTTP deadline. * IPC: new GET /peer/status (snapshot) and GET /peer/status/events (SSE). The SSE handler replays the current snapshot on connect. Tests cover applyPeerShare's three branches (enable, disable, Start failure rolls back the setting), the resume-if-enabled path, the Close-waits-for-resume + Stop-active-peer race, and the PatchSettings dispatch wiring (a typo on the diff key would silently break the toggle without it). peer_test adds a Subscribe-and-assert test for StatusEvent emission on both edges. go test -race ./peer/... ./backend/... ./common/settings/... ./ipc/... golangci-lint run --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/radiance.go | 107 +++++++++++++++++- backend/radiance_test.go | 212 +++++++++++++++++++++++++++++++++++- common/settings/settings.go | 11 +- ipc/server.go | 55 ++++++++++ peer/peer.go | 11 ++ peer/peer_test.go | 33 ++++++ 6 files changed, 422 insertions(+), 7 deletions(-) diff --git a/backend/radiance.go b/backend/radiance.go index f81155fc..43d4556e 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -35,6 +35,7 @@ import ( "github.com/getlantern/radiance/issue" "github.com/getlantern/radiance/kindling" "github.com/getlantern/radiance/log" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/telemetry" "github.com/getlantern/radiance/traces" @@ -46,6 +47,13 @@ import ( const tracerName = "github.com/getlantern/radiance/backend" +type peerController interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + IsActive() bool + CurrentStatus() peer.Status +} + // LocalBackend ties all the core functionality of Radiance together. It manages the configuration, // servers, VPN connection, account management, issue reporting, and telemetry for the application. type LocalBackend struct { @@ -61,6 +69,10 @@ type LocalBackend struct { splitTunnelMgr *vpn.SplitTunnel sessionHistory *vpn.SessionHistory + peerClient peerController + peerToggleMu sync.Mutex + peerWG sync.WaitGroup + shutdownFuncs []func() error closeOnce sync.Once stopChan chan struct{} @@ -156,6 +168,13 @@ func NewLocalBackend(ctx context.Context, opts Options) (*LocalBackend, error) { } vpnClient := vpn.NewVPNClient(dataDir, slog.Default().With("service", "vpn"), opts.PlatformInterface) + + peerAPI := peer.NewAPI(kindling.HTTPClient(), common.GetBaseURL(), platformDeviceID) + peerClient, err := peer.NewClient(peer.Config{API: peerAPI}) + if err != nil { + return nil, fmt.Errorf("failed to create peer client: %w", err) + } + ctx, cancel := context.WithCancel(ctx) cOpts := config.Options{ DataPath: dataDir, @@ -173,6 +192,7 @@ func NewLocalBackend(ctx context.Context, opts Options) (*LocalBackend, error) { srvManager: svrMgr, vpnClient: vpnClient, splitTunnelMgr: splitTunnelMgr, + peerClient: peerClient, shutdownFuncs: []func() error{ telemetry.Close, kindling.Close, }, @@ -215,6 +235,8 @@ func (r *LocalBackend) Start() { r.startAutoSelectedListener() r.startSessionAutoSelectListener() + r.resumePeerShareIfEnabled() + // set country code in settings when new config is received so it can be included in issue reports events.SubscribeOnce(func(evt config.NewConfigEvent) { if env.GetString(env.Country) != "" { @@ -300,6 +322,20 @@ func (r *LocalBackend) Start() { func (r *LocalBackend) Close() { r.closeOnce.Do(func() { slog.Debug("Closing Radiance") + // Wait for an in-flight peer auto-resume so we don't tear down ctx + // while it's mid-Start (which would leave a registered route + open + // box behind). Then stop with a fresh ctx so Deregister has a live + // HTTP deadline even though r.ctx is about to cancel. + if r.peerClient != nil { + r.peerWG.Wait() + if r.peerClient.IsActive() { + stopCtx, cancel := context.WithTimeout(context.Background(), peerStartTimeout) + if err := r.peerClient.Stop(stopCtx); err != nil { + slog.Warn("peer share stop on backend close returned error", "err", err) + } + cancel() + } + } if err := r.DisconnectVPN(); err != nil { slog.Error("Failed to disconnect VPN on shutdown", "error", err) } @@ -468,9 +504,78 @@ func (r *LocalBackend) PatchSettings(updates settings.Settings) error { if _, ok := diff[k]; ok { r.splitTunnelMgr.SetEnabled(settings.GetBool(k)) } - return r.maybeRestartVPN(diff) + if err := r.maybeRestartVPN(diff); err != nil { + return err + } + + if _, ok := diff[settings.PeerShareEnabledKey]; ok { + if err := r.applyPeerShare(settings.GetBool(settings.PeerShareEnabledKey)); err != nil { + return err + } + } + + return nil +} + +// applyPeerShare drives peerClient to match the toggle. On Start failure the +// persisted setting is rolled back so reads of PeerShareEnabledKey reflect +// runtime state. Stop errors are logged because a partial teardown shouldn't +// keep the toggle on. +// +// peerToggleMu serializes concurrent toggles: without it, a fast off→on→off +// sequence could see the second call's "already active" rollback racing the +// third call's Stop. +// +// peerStartTimeout caps blocking time on a slow router; UPnP M-SEARCH + +// /v1/peer/register normally complete in single-digit seconds. +func (r *LocalBackend) applyPeerShare(enabled bool) error { + r.peerToggleMu.Lock() + defer r.peerToggleMu.Unlock() + if enabled { + startCtx, cancel := context.WithTimeout(r.ctx, peerStartTimeout) + defer cancel() + if err := r.peerClient.Start(startCtx); err != nil { + if rbErr := settings.Patch(settings.Settings{settings.PeerShareEnabledKey: false}); rbErr != nil { + slog.Error("peer share rollback failed after Start error", + "start_err", err, "rollback_err", rbErr) + } + return fmt.Errorf("start peer share: %w", err) + } + return nil + } + if err := r.peerClient.Stop(r.ctx); err != nil { + slog.Warn("peer share stop returned error (toggle still off)", "err", err) + } + return nil } +// resumePeerShareIfEnabled re-Starts the peer client if the user left the +// toggle on across restarts. Runs in a goroutine because UPnP discovery and +// registration can take several seconds and Start() must return promptly. +// peerWG ensures Close waits for an in-flight resume to settle before +// teardown, so we never leave a registered route or a running box behind. +func (r *LocalBackend) resumePeerShareIfEnabled() { + if !settings.GetBool(settings.PeerShareEnabledKey) { + return + } + r.peerWG.Add(1) + go func() { + defer r.peerWG.Done() + if r.ctx.Err() != nil { + return + } + if err := r.applyPeerShare(true); err != nil { + slog.Warn("peer share auto-resume failed", "err", err) + } + }() +} + +func (r *LocalBackend) PeerStatus() peer.Status { + return r.peerClient.CurrentStatus() +} + +const peerStartTimeout = 30 * time.Second + // maybeRestartVPN restarts the VPN connection if either the ad block or smart routing settings // were changed and the VPN is currently connected. Returns an error if the VPN restart fails; // otherwise returns nil. diff --git a/backend/radiance_test.go b/backend/radiance_test.go index dd6eaa62..238d19b9 100644 --- a/backend/radiance_test.go +++ b/backend/radiance_test.go @@ -1,8 +1,218 @@ package backend import ( + "context" + "errors" + "sync" + "sync/atomic" "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/peer" ) -func TestBackend(t *testing.T) { +func TestBackend(t *testing.T) {} + +type fakePeerController struct { + startCalls atomic.Int64 + stopCalls atomic.Int64 + startErr error + active atomic.Bool +} + +func (f *fakePeerController) Start(_ context.Context) error { + f.startCalls.Add(1) + if f.startErr != nil { + return f.startErr + } + f.active.Store(true) + return nil +} + +func (f *fakePeerController) Stop(_ context.Context) error { + f.stopCalls.Add(1) + f.active.Store(false) + return nil +} + +func (f *fakePeerController) IsActive() bool { return f.active.Load() } +func (f *fakePeerController) CurrentStatus() peer.Status { return peer.Status{Active: f.active.Load()} } + +// newPeerTestBackend wires a minimal LocalBackend with only the fields +// applyPeerShare touches. settings is initialized to a fresh tempdir per test +// so the rollback path doesn't leak across runs. +func newPeerTestBackend(t *testing.T, fake *fakePeerController) *LocalBackend { + t.Helper() + require.NoError(t, settings.InitSettings(t.TempDir())) + t.Cleanup(settings.Reset) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + return &LocalBackend{ctx: ctx, peerClient: fake} +} + +func TestApplyPeerShare_StartsOnEnable(t *testing.T) { + fake := &fakePeerController{} + r := newPeerTestBackend(t, fake) + + require.NoError(t, r.applyPeerShare(true)) + assert.Equal(t, int64(1), fake.startCalls.Load()) + assert.Equal(t, int64(0), fake.stopCalls.Load()) + assert.True(t, fake.IsActive()) +} + +func TestApplyPeerShare_StopsOnDisable(t *testing.T) { + fake := &fakePeerController{} + r := newPeerTestBackend(t, fake) + fake.active.Store(true) + + require.NoError(t, r.applyPeerShare(false)) + assert.Equal(t, int64(0), fake.startCalls.Load()) + assert.Equal(t, int64(1), fake.stopCalls.Load()) + assert.False(t, fake.IsActive()) +} + +// On a Start failure we surface the error so the Dart side can roll back +// its UI, AND we flip the persisted setting back to false so the user-visible +// state matches reality on the next read. +func TestApplyPeerShare_StartFailureRollsBackSetting(t *testing.T) { + fake := &fakePeerController{startErr: errors.New("no upnp")} + r := newPeerTestBackend(t, fake) + + require.NoError(t, settings.Patch(settings.Settings{settings.PeerShareEnabledKey: true})) + require.True(t, settings.GetBool(settings.PeerShareEnabledKey)) + + err := r.applyPeerShare(true) + require.Error(t, err) + assert.ErrorContains(t, err, "no upnp") + assert.False(t, settings.GetBool(settings.PeerShareEnabledKey), + "setting must roll back to false after a Start failure") + assert.False(t, fake.IsActive()) +} + +func TestPeerStatus_Accessor(t *testing.T) { + fake := &fakePeerController{} + r := newPeerTestBackend(t, fake) + fake.active.Store(true) + + got := r.PeerStatus() + assert.True(t, got.Active) +} + +func TestResumePeerShare_NoopWhenSettingOff(t *testing.T) { + fake := &fakePeerController{} + r := newPeerTestBackend(t, fake) + + r.resumePeerShareIfEnabled() + r.peerWG.Wait() + assert.Equal(t, int64(0), fake.startCalls.Load()) +} + +func TestResumePeerShare_StartsWhenSettingOn(t *testing.T) { + fake := &fakePeerController{} + r := newPeerTestBackend(t, fake) + require.NoError(t, settings.Patch(settings.Settings{settings.PeerShareEnabledKey: true})) + + r.resumePeerShareIfEnabled() + r.peerWG.Wait() + assert.Equal(t, int64(1), fake.startCalls.Load()) + assert.True(t, fake.IsActive()) +} + +// Close must wait for an in-flight auto-resume Start before tearing down, +// then call Stop on the active session — otherwise we leave a registered +// route + open box behind on shutdown. +func TestClose_WaitsForResumeAndStopsActivePeer(t *testing.T) { + startGate := make(chan struct{}) + fake := &slowStartFake{gate: startGate} + r := newCloseableTestBackend(t, fake) + require.NoError(t, settings.Patch(settings.Settings{settings.PeerShareEnabledKey: true})) + + r.resumePeerShareIfEnabled() + + closeReturned := make(chan struct{}) + go func() { + r.Close() + close(closeReturned) + }() + + // Close must NOT return while the resume goroutine is still in Start. + select { + case <-closeReturned: + t.Fatal("Close returned before in-flight resume Start completed") + case <-time.After(50 * time.Millisecond): + } + + // Release Start. It returns nil → peer becomes active. Close must then + // observe IsActive() and call Stop. + close(startGate) + select { + case <-closeReturned: + case <-time.After(2 * time.Second): + t.Fatal("Close did not return after resume Start unblocked") + } + assert.Equal(t, int64(1), fake.startCalls.Load()) + assert.Equal(t, int64(1), fake.stopCalls.Load()) +} + +// slowStartFake blocks on gate until the test releases it, simulating a +// long UPnP discovery so we can race Close against Start. +type slowStartFake struct { + startCalls atomic.Int64 + stopCalls atomic.Int64 + active atomic.Bool + gate chan struct{} +} + +func (f *slowStartFake) Start(ctx context.Context) error { + f.startCalls.Add(1) + select { + case <-f.gate: + case <-ctx.Done(): + return ctx.Err() + } + f.active.Store(true) + return nil +} +func (f *slowStartFake) Stop(_ context.Context) error { + f.stopCalls.Add(1) + f.active.Store(false) + return nil +} +func (f *slowStartFake) IsActive() bool { return f.active.Load() } +func (f *slowStartFake) CurrentStatus() peer.Status { return peer.Status{Active: f.active.Load()} } + +// newCloseableTestBackend mirrors newPeerTestBackend but provides the fields +// Close needs (closeOnce, stopChan, cancel) so we can exercise the shutdown +// path end-to-end. +func newCloseableTestBackend(t *testing.T, fake peerController) *LocalBackend { + t.Helper() + require.NoError(t, settings.InitSettings(t.TempDir())) + t.Cleanup(settings.Reset) + ctx, cancel := context.WithCancel(context.Background()) + return &LocalBackend{ + ctx: ctx, + cancel: cancel, + peerClient: fake, + stopChan: make(chan struct{}), + closeOnce: sync.Once{}, + } +} + +// Verify the PatchSettings dispatch actually routes PeerShareEnabledKey to +// applyPeerShare. A typo on the diff key would silently break the toggle. +func TestPatchSettings_PeerShareDispatches(t *testing.T) { + fake := &fakePeerController{} + r := newPeerTestBackend(t, fake) + + require.NoError(t, r.PatchSettings(settings.Settings{settings.PeerShareEnabledKey: true})) + assert.Equal(t, int64(1), fake.startCalls.Load()) + assert.True(t, fake.IsActive()) + + require.NoError(t, r.PatchSettings(settings.Settings{settings.PeerShareEnabledKey: false})) + assert.Equal(t, int64(1), fake.stopCalls.Load()) + assert.False(t, fake.IsActive()) } diff --git a/common/settings/settings.go b/common/settings/settings.go index 23ef0c59..3a3afd90 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -51,11 +51,12 @@ const ( OAuthProviderKey _key = "oauth_provider" // string (e.g. "google", "apple", "email") // VPN related keys. - SmartRoutingKey _key = "smart_routing" // bool - SplitTunnelKey _key = "split_tunnel" // bool - AdBlockKey _key = "ad_block" // bool - AutoConnectKey _key = "auto_connect" // bool - SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored + SmartRoutingKey _key = "smart_routing" // bool + SplitTunnelKey _key = "split_tunnel" // bool + AdBlockKey _key = "ad_block" // bool + AutoConnectKey _key = "auto_connect" // bool + PeerShareEnabledKey _key = "peer_share_enabled" // bool + SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] diff --git a/ipc/server.go b/ipc/server.go index 27ff1175..07c417d0 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -22,6 +22,7 @@ import ( "github.com/getlantern/radiance/config" "github.com/getlantern/radiance/events" rlog "github.com/getlantern/radiance/log" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/vpn" sjson "github.com/sagernet/sing/common/json" @@ -63,6 +64,10 @@ const ( featuresEndpoint = "/settings/features" settingsEndpoint = "/settings" + // Peer-share ("Share My Connection") endpoints + peerStatusEndpoint = "/peer/status" + peerStatusEventsEndpoint = "/peer/status/events" + // Split tunnel endpoint splitTunnelEndpoint = "/split-tunnel" @@ -225,6 +230,11 @@ func newLocalAPI(b *backend.LocalBackend, withAuth bool) *localapi { mux.HandleFunc("GET "+featuresEndpoint, traced(s.featuresHandler)) mux.HandleFunc(settingsEndpoint, traced(s.settingsHandler)) + // Peer share + mux.HandleFunc("GET "+peerStatusEndpoint, traced(s.peerStatusHandler)) + // SSE skips the tracer middleware since it buffers the entire response body. + mux.HandleFunc("GET "+peerStatusEventsEndpoint, s.peerStatusEventsHandler) + // Split tunnel mux.HandleFunc(splitTunnelEndpoint, traced(s.splitTunnelHandler)) @@ -458,6 +468,51 @@ func (s *localapi) vpnStatusEventsHandler(w http.ResponseWriter, r *http.Request } } +///////////////// +// Peer share // +///////////////// + +func (s *localapi) peerStatusHandler(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.backend(r.Context()).PeerStatus()) +} + +// peerStatusEventsHandler streams peer.StatusEvent over SSE. Replays the +// current snapshot first so a subscriber attaching between events still sees +// the live state. +func (s *localapi) peerStatusEventsHandler(w http.ResponseWriter, r *http.Request) { + flusher := sseWriter(w) + if flusher == nil { + return + } + ch := make(chan []byte, 16) + sub := events.Subscribe(func(evt peer.StatusEvent) { + data, err := json.Marshal(evt) + if err != nil { + return + } + select { + case ch <- data: + default: + } + }) + defer sub.Unsubscribe() + + if data, err := json.Marshal(peer.StatusEvent{Status: s.backend(r.Context()).PeerStatus()}); err == nil { + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + + for { + select { + case data := <-ch: + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + /////////////////////// // Server selection // /////////////////////// diff --git a/peer/peer.go b/peer/peer.go index a70ad7ca..dbf4154e 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -12,9 +12,17 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" + "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) +// StatusEvent fires whenever the Client's session state changes — successful +// Start, user Stop, or auto-Stop on a 404 heartbeat. +type StatusEvent struct { + events.Event + Status Status `json:"status"` +} + // Port range chosen to minimize collision risk on the typical home network, // not to guarantee one. 30000–50000 sits above the well-known/system range // (0–1023) and above the ports most services use by default (web/dev/dbs @@ -243,6 +251,7 @@ func (c *Client) Start(ctx context.Context) error { ExternalPort: mapping.ExternalPort, RouteID: regResp.RouteID, } + statusSnapshot := c.status c.mu.Unlock() fwd.StartRenewal(runCtx) @@ -257,6 +266,7 @@ func (c *Client) Start(ctx context.Context) error { "heartbeat", heartbeat, ) success = true + events.Emit(StatusEvent{Status: statusSnapshot}) return nil } @@ -320,6 +330,7 @@ func (c *Client) Stop(ctx context.Context) error { slog.Warn("peer client unmap port failed", "err", err) } slog.Info("peer client stopped", "route_id", routeID) + events.Emit(StatusEvent{Status: Status{}}) return firstErr } diff --git a/peer/peer_test.go b/peer/peer_test.go index 5872419b..dd24c0ea 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -575,5 +576,37 @@ func TestAPIError_StringFormat(t *testing.T) { assert.Contains(t, e.Error(), "could not connect") } +// Subscribers (the IPC SSE handler in production) need both edges so the UI +// can render fresh state without polling. +func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) { + fwd := &fakeForwarder{} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + got := make(chan StatusEvent, 4) + sub := events.Subscribe(func(evt StatusEvent) { + got <- evt + }) + defer sub.Unsubscribe() + + require.NoError(t, c.Start(context.Background())) + select { + case evt := <-got: + assert.True(t, evt.Status.Active) + assert.NotEmpty(t, evt.Status.RouteID) + case <-time.After(time.Second): + t.Fatal("no Start status event within 1s") + } + + require.NoError(t, c.Stop(context.Background())) + select { + case evt := <-got: + assert.False(t, evt.Status.Active) + case <-time.After(time.Second): + t.Fatal("no Stop status event within 1s") + } +} + var _ portForwarder = (*fakeForwarder)(nil) var _ boxService = (*fakeBoxService)(nil) From 20ced1e1b1a9d4c867351caa4b0da9e5e578f871 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 05:36:41 -0600 Subject: [PATCH 08/63] review: bound peer Stop, drop event-payload reliance in SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from review on #460. 1) applyPeerShare's disable branch was calling peer.Stop with r.ctx (no deadline). Deregister or UnmapPort stalling on a slow gateway would hang the IPC /settings PATCH and leave the UI toggle without a response. Wrap both branches in a single peerToggleTimeout-bounded context (renamed from peerStartTimeout to reflect that it now covers both directions). 2) The SSE handler streamed the StatusEvent's captured snapshot, but events.Emit dispatches each subscriber callback in its own goroutine so a quick start→stop pair could land in the channel out of order — the consumer would briefly see "active" *after* "inactive". Reworked the handler to use the event purely as a wake-up trigger and read the live snapshot from PeerStatus() before each send. Out-of-order trigger goroutines now just produce duplicate reads of the same final state instead of stale-state flicker. go test -race ./peer/... ./backend/... ./ipc/... and golangci-lint both clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/radiance.go | 14 +++++++------- ipc/server.go | 31 +++++++++++++++++-------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/backend/radiance.go b/backend/radiance.go index 43d4556e..32fec04d 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -329,7 +329,7 @@ func (r *LocalBackend) Close() { if r.peerClient != nil { r.peerWG.Wait() if r.peerClient.IsActive() { - stopCtx, cancel := context.WithTimeout(context.Background(), peerStartTimeout) + stopCtx, cancel := context.WithTimeout(context.Background(), peerToggleTimeout) if err := r.peerClient.Stop(stopCtx); err != nil { slog.Warn("peer share stop on backend close returned error", "err", err) } @@ -526,15 +526,15 @@ func (r *LocalBackend) PatchSettings(updates settings.Settings) error { // sequence could see the second call's "already active" rollback racing the // third call's Stop. // -// peerStartTimeout caps blocking time on a slow router; UPnP M-SEARCH + +// peerToggleTimeout caps blocking time on a slow router; UPnP M-SEARCH + // /v1/peer/register normally complete in single-digit seconds. func (r *LocalBackend) applyPeerShare(enabled bool) error { r.peerToggleMu.Lock() defer r.peerToggleMu.Unlock() + toggleCtx, cancel := context.WithTimeout(r.ctx, peerToggleTimeout) + defer cancel() if enabled { - startCtx, cancel := context.WithTimeout(r.ctx, peerStartTimeout) - defer cancel() - if err := r.peerClient.Start(startCtx); err != nil { + if err := r.peerClient.Start(toggleCtx); err != nil { if rbErr := settings.Patch(settings.Settings{settings.PeerShareEnabledKey: false}); rbErr != nil { slog.Error("peer share rollback failed after Start error", "start_err", err, "rollback_err", rbErr) @@ -543,7 +543,7 @@ func (r *LocalBackend) applyPeerShare(enabled bool) error { } return nil } - if err := r.peerClient.Stop(r.ctx); err != nil { + if err := r.peerClient.Stop(toggleCtx); err != nil { slog.Warn("peer share stop returned error (toggle still off)", "err", err) } return nil @@ -574,7 +574,7 @@ func (r *LocalBackend) PeerStatus() peer.Status { return r.peerClient.CurrentStatus() } -const peerStartTimeout = 30 * time.Second +const peerToggleTimeout = 30 * time.Second // maybeRestartVPN restarts the VPN connection if either the ad block or smart routing settings // were changed and the VPN is currently connected. Returns an error if the VPN restart fails; diff --git a/ipc/server.go b/ipc/server.go index 07c417d0..114443b7 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -476,37 +476,40 @@ func (s *localapi) peerStatusHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.backend(r.Context()).PeerStatus()) } -// peerStatusEventsHandler streams peer.StatusEvent over SSE. Replays the -// current snapshot first so a subscriber attaching between events still sees -// the live state. +// peerStatusEventsHandler streams peer.StatusEvent over SSE. The wire +// payload is always the live snapshot from PeerStatus(), not the event's +// captured value: events.Emit dispatches each callback on its own goroutine +// so a quick start→stop pair can land out of order, and we'd send a stale +// "active" after a "inactive". Reading the live snapshot when the trigger +// fires guarantees the SSE consumer's last message reflects current state. func (s *localapi) peerStatusEventsHandler(w http.ResponseWriter, r *http.Request) { flusher := sseWriter(w) if flusher == nil { return } - ch := make(chan []byte, 16) - sub := events.Subscribe(func(evt peer.StatusEvent) { - data, err := json.Marshal(evt) - if err != nil { - return - } + trigger := make(chan struct{}, 1) + sub := events.Subscribe(func(_ peer.StatusEvent) { select { - case ch <- data: + case trigger <- struct{}{}: default: } }) defer sub.Unsubscribe() - if data, err := json.Marshal(peer.StatusEvent{Status: s.backend(r.Context()).PeerStatus()}); err == nil { + send := func() { + data, err := json.Marshal(peer.StatusEvent{Status: s.backend(r.Context()).PeerStatus()}) + if err != nil { + return + } _, _ = fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } + send() // replay current snapshot for subscribers attaching between events for { select { - case data := <-ch: - _, _ = fmt.Fprintf(w, "data: %s\n\n", data) - flusher.Flush() + case <-trigger: + send() case <-r.Context().Done(): return } From 49cc820a3292cbd58ce629345c9fc60a64253df1 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 6 May 2026 06:58:13 -0600 Subject: [PATCH 09/63] peer: fix nil-pointer panic when UPnP discovery fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to make the failure-on-discover path actually surface its underlying error instead of crashing the IPC handler. 1. peer/peer.go's default cfg.NewForwarder wrapped portforward.NewForwarder with a bare `return portforward.NewForwarder(ctx)`. When discovery failed, that collapsed the `(*Forwarder)(nil), err` pair into a typed-nil interface — `if fwd != nil` in the deferred cleanup passed (the interface has a type), `fwd.UnmapPort(...)` dispatched to a nil receiver, and `f.mu.Lock()` panicked. The wrapper now returns a clean `nil, err` so the caller sees ErrNoPortForwarding (or whatever the discoverer returned) and the deferred cleanup short-circuits on the interface nil-check. 2. portforward.UnmapPort grew a defensive `if f == nil { return nil }` at the top. Belt-and-suspenders for any future caller that lands here through an interface and bypasses the inline nil-check — teardown should be idempotent on a nil receiver, not a panic. Reproduced live on macOS 26.x with `Share My Connection` toggled on when UPnP discovery returned ErrNoPortForwarding; the http2 IPC goroutine panicked instead of rolling back the toggle. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 13 ++++++++++++- portforward/portforward.go | 8 ++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/peer/peer.go b/peer/peer.go index dbf4154e..500ae7a5 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -111,7 +111,18 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { - return portforward.NewForwarder(ctx) + // Explicitly return a nil interface on error — `return + // portforward.NewForwarder(ctx)` collapses the (*Forwarder, error) + // pair into a typed-nil interface on failure, which then panics + // inside the deferred cleanup's `if fwd != nil { fwd.UnmapPort... }` + // because the nil-check passes (interface has a type) but the + // receiver is nil. Surfacing the underlying error here lets the + // caller see ErrNoPortForwarding instead of a runtime panic. + fwd, err := portforward.NewForwarder(ctx) + if err != nil { + return nil, err + } + return fwd, nil } } if cfg.BuildBoxService == nil { diff --git a/portforward/portforward.go b/portforward/portforward.go index 6a8fe9c1..576f7e38 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -130,6 +130,14 @@ func (f *Forwarder) MapPort(ctx context.Context, internalPort uint16, descriptio // about a router rule that's actually still live and the user would have // to wait for the UPnP lease to expire. func (f *Forwarder) UnmapPort(ctx context.Context) error { + // Defensive: callers that pass a *Forwarder through an interface (see + // peer.Client's portForwarder shim) can land here with f == nil if a + // failed construction collapsed `(*Forwarder)(nil), err` into a + // non-nil-but-typed-nil interface. A bare `f.mu.Lock()` would panic; + // this guard makes the cleanup path idempotent against that race. + if f == nil { + return nil + } f.mu.Lock() defer f.mu.Unlock() if f.cancel != nil { From 8235b4db599b402db7c0d031aed6f2e5e2770a36 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 14:08:13 -0600 Subject: [PATCH 10/63] backend: guard DisconnectVPN call in Close() against nil vpnClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflict-resolution from the May 28 rebase brought the new `r.DisconnectVPN()` call (added on radiance/main since this PR's original branch point) into Close(). Peer-focused unit tests that construct partial LocalBackends without a vpnClient (TestClose_WaitsForResumeAndStopsActivePeer in particular) now hit a nil-pointer panic because Close calls through to `r.vpnClient.Disconnect()` unconditionally. Mirror the existing `r.peerClient != nil` guard immediately above — production NewLocalBackend always sets vpnClient, but defensive-null in the shutdown path costs nothing and keeps the peer test scaffolding viable. Co-Authored-By: Claude Opus 4.7 --- backend/radiance.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/radiance.go b/backend/radiance.go index 32fec04d..6a69c47a 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -336,8 +336,14 @@ func (r *LocalBackend) Close() { cancel() } } - if err := r.DisconnectVPN(); err != nil { - slog.Error("Failed to disconnect VPN on shutdown", "error", err) + // vpnClient is always set in production via NewLocalBackend, but + // peer-focused unit tests construct partial LocalBackends without + // one. Guard the call so Close stays robust under those paths + // rather than panicking in DisconnectVPN. + if r.vpnClient != nil { + if err := r.DisconnectVPN(); err != nil { + slog.Error("Failed to disconnect VPN on shutdown", "error", err) + } } r.cancel() // cancels context, unsubscribes all event listeners and stops child goroutines close(r.stopChan) From 7d616d6e740927d21755b98085112b1e11477672 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 14:15:08 -0600 Subject: [PATCH 11/63] backend: extract peer-share logic into peer_share.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the peer.Client wiring out of backend/radiance.go (was 1262 lines) into a new same-package file so the dispatch / lifecycle / IPC accessor all live together rather than scattered across 80+ lines of the main file. Moved to backend/peer_share.go: - peerController interface (the test-friendly subset of *peer.Client) - peerToggleTimeout constant - newPeerClient: peer.Client construction helper (used by NewLocalBackend) - applyPeerShare method - resumePeerShareIfEnabled method - closePeerClient method (used by Close; absorbs the nil-vpnClient guard pattern and the in-flight-resume Wait that were inline before) - PeerStatus method backend/radiance.go now holds only the struct-field declarations and six call sites (NewLocalBackend, Start, Close, PatchSettings dispatch). No "github.com/getlantern/radiance/peer" import in radiance.go anymore. Net diff: -84 lines in radiance.go (1262 → 1181), +121 lines in peer_share.go. Tests pass. Co-Authored-By: Claude Opus 4.7 --- backend/peer_share.go | 121 ++++++++++++++++++++++++++++++++++++++++++ backend/radiance.go | 87 ++---------------------------- 2 files changed, 124 insertions(+), 84 deletions(-) create mode 100644 backend/peer_share.go diff --git a/backend/peer_share.go b/backend/peer_share.go new file mode 100644 index 00000000..93edbe5f --- /dev/null +++ b/backend/peer_share.go @@ -0,0 +1,121 @@ +package backend + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/kindling" + "github.com/getlantern/radiance/peer" +) + +// peerController is the subset of *peer.Client that LocalBackend needs. +// Defined as an interface so tests can swap in a fake without standing up +// real UPnP / sing-box / lantern-cloud dependencies. +type peerController interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + IsActive() bool + CurrentStatus() peer.Status +} + +// peerToggleTimeout caps blocking time on a slow router; UPnP M-SEARCH + +// /v1/peer/register normally complete in single-digit seconds. Also used as +// the deadline for Stop on backend close so a stalled deregister can't hang +// shutdown. +const peerToggleTimeout = 30 * time.Second + +// newPeerClient constructs the production peer.Client wired against the +// shared kindling HTTP client and the platform device ID. Pulled out of +// NewLocalBackend so the construction site is a one-liner. +func newPeerClient(platformDeviceID string) (*peer.Client, error) { + api := peer.NewAPI(kindling.HTTPClient(), common.GetBaseURL(), platformDeviceID) + client, err := peer.NewClient(peer.Config{API: api}) + if err != nil { + return nil, fmt.Errorf("failed to create peer client: %w", err) + } + return client, nil +} + +// applyPeerShare drives peerClient to match the toggle. On Start failure the +// persisted setting is rolled back so reads of PeerShareEnabledKey reflect +// runtime state. Stop errors are logged because a partial teardown shouldn't +// keep the toggle on. +// +// peerToggleMu serializes concurrent toggles: without it, a fast off→on→off +// sequence could see the second call's "already active" rollback racing the +// third call's Stop. +func (r *LocalBackend) applyPeerShare(enabled bool) error { + r.peerToggleMu.Lock() + defer r.peerToggleMu.Unlock() + toggleCtx, cancel := context.WithTimeout(r.ctx, peerToggleTimeout) + defer cancel() + if enabled { + if err := r.peerClient.Start(toggleCtx); err != nil { + if rbErr := settings.Patch(settings.Settings{settings.PeerShareEnabledKey: false}); rbErr != nil { + slog.Error("peer share rollback failed after Start error", + "start_err", err, "rollback_err", rbErr) + } + return fmt.Errorf("start peer share: %w", err) + } + return nil + } + if err := r.peerClient.Stop(toggleCtx); err != nil { + slog.Warn("peer share stop returned error (toggle still off)", "err", err) + } + return nil +} + +// resumePeerShareIfEnabled re-Starts the peer client if the user left the +// toggle on across restarts. Runs in a goroutine because UPnP discovery and +// registration can take several seconds and Start() must return promptly. +// peerWG ensures Close waits for an in-flight resume to settle before +// teardown, so we never leave a registered route or a running box behind. +func (r *LocalBackend) resumePeerShareIfEnabled() { + if !settings.GetBool(settings.PeerShareEnabledKey) { + return + } + r.peerWG.Add(1) + go func() { + defer r.peerWG.Done() + if r.ctx.Err() != nil { + return + } + if err := r.applyPeerShare(true); err != nil { + slog.Warn("peer share auto-resume failed", "err", err) + } + }() +} + +// closePeerClient runs at backend shutdown. It waits for any in-flight +// auto-resume Start to finish (so we don't tear down ctx while it's still +// setting things up — that would leave a registered route + open box +// behind) and then stops the peer client with a fresh ctx so Deregister +// and UnmapPort have a live HTTP deadline even though r.ctx is about to +// cancel. +// +// No-op when peerClient is nil (peer-focused unit tests that construct +// partial LocalBackends). +func (r *LocalBackend) closePeerClient() { + if r.peerClient == nil { + return + } + r.peerWG.Wait() + if !r.peerClient.IsActive() { + return + } + stopCtx, cancel := context.WithTimeout(context.Background(), peerToggleTimeout) + defer cancel() + if err := r.peerClient.Stop(stopCtx); err != nil { + slog.Warn("peer share stop on backend close returned error", "err", err) + } +} + +// PeerStatus returns the current peer-share session state for the IPC +// /peer/status endpoint. +func (r *LocalBackend) PeerStatus() peer.Status { + return r.peerClient.CurrentStatus() +} diff --git a/backend/radiance.go b/backend/radiance.go index 6a69c47a..4a63f07a 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -35,7 +35,6 @@ import ( "github.com/getlantern/radiance/issue" "github.com/getlantern/radiance/kindling" "github.com/getlantern/radiance/log" - "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/telemetry" "github.com/getlantern/radiance/traces" @@ -47,13 +46,6 @@ import ( const tracerName = "github.com/getlantern/radiance/backend" -type peerController interface { - Start(ctx context.Context) error - Stop(ctx context.Context) error - IsActive() bool - CurrentStatus() peer.Status -} - // LocalBackend ties all the core functionality of Radiance together. It manages the configuration, // servers, VPN connection, account management, issue reporting, and telemetry for the application. type LocalBackend struct { @@ -169,10 +161,9 @@ func NewLocalBackend(ctx context.Context, opts Options) (*LocalBackend, error) { vpnClient := vpn.NewVPNClient(dataDir, slog.Default().With("service", "vpn"), opts.PlatformInterface) - peerAPI := peer.NewAPI(kindling.HTTPClient(), common.GetBaseURL(), platformDeviceID) - peerClient, err := peer.NewClient(peer.Config{API: peerAPI}) + peerClient, err := newPeerClient(platformDeviceID) if err != nil { - return nil, fmt.Errorf("failed to create peer client: %w", err) + return nil, err } ctx, cancel := context.WithCancel(ctx) @@ -322,20 +313,7 @@ func (r *LocalBackend) Start() { func (r *LocalBackend) Close() { r.closeOnce.Do(func() { slog.Debug("Closing Radiance") - // Wait for an in-flight peer auto-resume so we don't tear down ctx - // while it's mid-Start (which would leave a registered route + open - // box behind). Then stop with a fresh ctx so Deregister has a live - // HTTP deadline even though r.ctx is about to cancel. - if r.peerClient != nil { - r.peerWG.Wait() - if r.peerClient.IsActive() { - stopCtx, cancel := context.WithTimeout(context.Background(), peerToggleTimeout) - if err := r.peerClient.Stop(stopCtx); err != nil { - slog.Warn("peer share stop on backend close returned error", "err", err) - } - cancel() - } - } + r.closePeerClient() // vpnClient is always set in production via NewLocalBackend, but // peer-focused unit tests construct partial LocalBackends without // one. Guard the call so Close stays robust under those paths @@ -523,65 +501,6 @@ func (r *LocalBackend) PatchSettings(updates settings.Settings) error { return nil } -// applyPeerShare drives peerClient to match the toggle. On Start failure the -// persisted setting is rolled back so reads of PeerShareEnabledKey reflect -// runtime state. Stop errors are logged because a partial teardown shouldn't -// keep the toggle on. -// -// peerToggleMu serializes concurrent toggles: without it, a fast off→on→off -// sequence could see the second call's "already active" rollback racing the -// third call's Stop. -// -// peerToggleTimeout caps blocking time on a slow router; UPnP M-SEARCH + -// /v1/peer/register normally complete in single-digit seconds. -func (r *LocalBackend) applyPeerShare(enabled bool) error { - r.peerToggleMu.Lock() - defer r.peerToggleMu.Unlock() - toggleCtx, cancel := context.WithTimeout(r.ctx, peerToggleTimeout) - defer cancel() - if enabled { - if err := r.peerClient.Start(toggleCtx); err != nil { - if rbErr := settings.Patch(settings.Settings{settings.PeerShareEnabledKey: false}); rbErr != nil { - slog.Error("peer share rollback failed after Start error", - "start_err", err, "rollback_err", rbErr) - } - return fmt.Errorf("start peer share: %w", err) - } - return nil - } - if err := r.peerClient.Stop(toggleCtx); err != nil { - slog.Warn("peer share stop returned error (toggle still off)", "err", err) - } - return nil -} - -// resumePeerShareIfEnabled re-Starts the peer client if the user left the -// toggle on across restarts. Runs in a goroutine because UPnP discovery and -// registration can take several seconds and Start() must return promptly. -// peerWG ensures Close waits for an in-flight resume to settle before -// teardown, so we never leave a registered route or a running box behind. -func (r *LocalBackend) resumePeerShareIfEnabled() { - if !settings.GetBool(settings.PeerShareEnabledKey) { - return - } - r.peerWG.Add(1) - go func() { - defer r.peerWG.Done() - if r.ctx.Err() != nil { - return - } - if err := r.applyPeerShare(true); err != nil { - slog.Warn("peer share auto-resume failed", "err", err) - } - }() -} - -func (r *LocalBackend) PeerStatus() peer.Status { - return r.peerClient.CurrentStatus() -} - -const peerToggleTimeout = 30 * time.Second - // maybeRestartVPN restarts the VPN connection if either the ad block or smart routing settings // were changed and the VPN is currently connected. Returns an error if the VPN restart fails; // otherwise returns nil. From 36aeb36f323f6f96012dc1c453f74ae0030a63d7 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 6 May 2026 14:01:07 -0600 Subject: [PATCH 12/63] peer: call /peer/verify after starting sing-box; fix doubled /v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that pair with the lantern-cloud /peer/verify split: 1. peer/api.go: drop the leading /v1 from peer endpoint paths. baseURL already ends with /api/v1 (from common.GetBaseURL), so /v1/peer/register was hitting /api/v1/v1/peer/register on prod and 404'ing. Every other radiance API caller appends without /v1 (config/fetcher.go, issue/issue.go); peer/api.go was the odd one out. Updated NewAPI's docstring to spell out the convention. 2. peer/peer.go: after box.Start succeeds, call API.Verify(routeID). The server's verifier dials back through the peer's external port using the just-built creds, so the inbound has to be listening before verify runs. Splitting verify out of register resolves the chicken-and-egg where register-time verify could never see a peer that didn't yet have its cert. Verify failure here is fatal — the server has already deprecated the row, so the deferred cleanup tears the rest of the session down. 3. peer/api.go: new API.Verify(ctx, routeID) wrapping POST /peer/verify. Tests: stubServer's mux handles the new /peer/verify route plus verifyCount / verifyDeviceID / verifyStatus knobs. Existing tests exercise the new step transparently because they use the default verifyStatus=200. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/api.go | 25 ++++++++++++++++++++----- peer/peer.go | 11 +++++++++++ peer/peer_test.go | 19 ++++++++++++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/peer/api.go b/peer/api.go index 05752402..06fae20e 100644 --- a/peer/api.go +++ b/peer/api.go @@ -47,32 +47,47 @@ type API struct { deviceID string } -// NewAPI constructs the client. baseURL must not have a trailing slash and -// must not include "/v1" — that's appended per-endpoint. +// NewAPI constructs the client. baseURL must already include the API +// version prefix (matches common.GetBaseURL() which returns ".../api/v1"); +// per-endpoint paths are appended without re-adding /v1, mirroring every +// other radiance caller of common.GetBaseURL (config/fetcher.go, +// issue/issue.go, etc.). func NewAPI(httpClient *http.Client, baseURL, deviceID string) *API { return &API{httpClient: httpClient, baseURL: baseURL, deviceID: deviceID} } func (a *API) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) { var resp RegisterResponse - if err := a.do(ctx, http.MethodPost, "/v1/peer/register", req, &resp); err != nil { + if err := a.do(ctx, http.MethodPost, "/peer/register", req, &resp); err != nil { return nil, fmt.Errorf("register: %w", err) } return &resp, nil } +// Verify asks lantern-cloud to dial the peer's external endpoint through a +// freshly-built samizdat client. Called after Start has finished bringing +// up sing-box locally so the server's verifier hits a live listener with +// the matching creds. Server-side failure deprecates the row + returns +// 422; the caller treats that as a fatal Start error and tears down. +func (a *API) Verify(ctx context.Context, routeID string) error { + if err := a.do(ctx, http.MethodPost, "/peer/verify", LifecycleRequest{RouteID: routeID}, nil); err != nil { + return fmt.Errorf("verify: %w", err) + } + return nil +} + // Heartbeat extends the peer route's TTL. The server owner-gates via // X-Lantern-Device-Id, so a leaked route_id can't be used by another device // to keep the registration alive. func (a *API) Heartbeat(ctx context.Context, routeID string) error { - if err := a.do(ctx, http.MethodPost, "/v1/peer/heartbeat", LifecycleRequest{RouteID: routeID}, nil); err != nil { + if err := a.do(ctx, http.MethodPost, "/peer/heartbeat", LifecycleRequest{RouteID: routeID}, nil); err != nil { return fmt.Errorf("heartbeat: %w", err) } return nil } func (a *API) Deregister(ctx context.Context, routeID string) error { - if err := a.do(ctx, http.MethodPost, "/v1/peer/deregister", LifecycleRequest{RouteID: routeID}, nil); err != nil { + if err := a.do(ctx, http.MethodPost, "/peer/deregister", LifecycleRequest{RouteID: routeID}, nil); err != nil { return fmt.Errorf("deregister: %w", err) } return nil diff --git a/peer/peer.go b/peer/peer.go index 500ae7a5..15c8b968 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -233,6 +233,17 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("start sing-box: %w", err) } + // Now that sing-box is listening with the just-built creds, ask the + // server to dial back through them. Splitting verify out of Register + // into this explicit follow-up avoids the chicken-and-egg where the + // server tried to verify before the peer could possibly be listening + // (the cert/key only arrive in the Register response). Failure here + // is fatal — the server has already deprecated the row, so the + // deferred cleanup tears the rest of the session down. + if err := c.cfg.API.Verify(ctx, regResp.RouteID); err != nil { + return fmt.Errorf("verify with lantern-cloud: %w", err) + } + // HeartbeatIntervalSeconds is server-driven so lantern-cloud can dial up // the cadence on registrations it wants to expire faster. Honor any // positive value verbatim — clamping short intervals up would defeat diff --git a/peer/peer_test.go b/peer/peer_test.go index dd24c0ea..9734e53a 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -139,12 +139,15 @@ type stubServer struct { server *httptest.Server registerStatus int registerResp RegisterResponse + verifyStatus int heartbeatStatus int deregisterStatus int registerCount atomic.Int64 + verifyCount atomic.Int64 heartbeatCount atomic.Int64 deregisterCount atomic.Int64 registerDeviceID atomic.Value // string + verifyDeviceID atomic.Value // string heartbeatDeviceID atomic.Value // string deregisterDeviceID atomic.Value // string lastRegisterReq atomic.Value // RegisterRequest @@ -155,6 +158,7 @@ func newStubServer(t *testing.T) *stubServer { s := &stubServer{ t: t, registerStatus: http.StatusOK, + verifyStatus: http.StatusOK, heartbeatStatus: http.StatusOK, deregisterStatus: http.StatusOK, registerResp: RegisterResponse{ @@ -164,7 +168,7 @@ func newStubServer(t *testing.T) *stubServer { }, } mux := http.NewServeMux() - mux.HandleFunc("/v1/peer/register", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/peer/register", func(w http.ResponseWriter, r *http.Request) { s.registerCount.Add(1) s.registerDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) var req RegisterRequest @@ -176,7 +180,16 @@ func newStubServer(t *testing.T) *stubServer { } _ = json.NewEncoder(w).Encode(s.registerResp) }) - mux.HandleFunc("/v1/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/peer/verify", func(w http.ResponseWriter, r *http.Request) { + s.verifyCount.Add(1) + s.verifyDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) + if s.verifyStatus != http.StatusOK { + http.Error(w, "verify failed", s.verifyStatus) + return + } + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { s.heartbeatCount.Add(1) s.heartbeatDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) if s.heartbeatStatus != http.StatusOK { @@ -185,7 +198,7 @@ func newStubServer(t *testing.T) *stubServer { } w.WriteHeader(http.StatusOK) }) - mux.HandleFunc("/v1/peer/deregister", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/peer/deregister", func(w http.ResponseWriter, r *http.Request) { s.deregisterCount.Add(1) s.deregisterDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) if s.deregisterStatus != http.StatusOK { From 856c49ecf7ab65b86378a721bc1880d32b89df92 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 6 May 2026 14:42:04 -0600 Subject: [PATCH 13/63] peer: register lantern-box protocols in box ctx + regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defaultBuildBoxService used to call libbox.NewServiceWithContext with the caller's bare ctx, which has no lantern-box protocol registries plumbed in. The samizdat inbound type ServerConfig sends back from /peer/register isn't a built-in sing-box protocol, so libbox's JSON decoder couldn't resolve inbounds[0].type="samizdat" and returned "missing inbound fields registry in context". The integration tests stub BuildBoxService entirely, so this layer was never exercised in CI — only surfaced live during the eero end-to-end test. Two pieces: 1. Use box.BaseContext() (from getlantern/lantern-box) when calling libbox.NewServiceWithContext. That ctx has the InboundOptionsRegistry populated with samizdat / reflex / etc. so the decode succeeds. Coexists with the user's VPN tunnel (vpn/tunnel.go) — libbox.Setup is process-global, the ctx registries are per-box. 2. TestDefaultBuildBoxService_DecodesSamizdatInbound walks the actual decode path with a minimal samizdat-inbound JSON. Verified to fail with the exact production error message under the pre-fix code, pass under the fix. Cuts the diagnostic loop from a 5-minute rebuild+redeploy+toggle cycle to a 0.5s test failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 19 +++++++++++++++++-- peer/peer_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index 15c8b968..8cda512c 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" + box "github.com/getlantern/lantern-box" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -457,8 +458,22 @@ func pickInternalPort() uint16 { // platform-VPN integration the way the main VPN tunnel does. The samizdat // inbound is just an HTTPS server bound to a TCP port; sing-box's default // network stack handles it. -func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) { - bs, err := libbox.NewServiceWithContext(ctx, options, nil) +// +// box.BaseContext registers the lantern-box protocol fields registries +// (samizdat, reflex, etc.) into the ctx so libbox can decode the +// inbounds[0].type="samizdat" stanza coming back from /peer/register. +// Without it the user's ctx is missing InboundOptionsRegistry and +// libbox returns "missing inbound fields registry in context" — the +// failure mode is silent in CI because the integration tests stub +// BuildBoxService entirely; only TestDefaultBuildBoxService_DecodesSamizdatInbound +// exercises the real decode path. +// +// Runs in the same process as the user's VPN tunnel (vpn/tunnel.go), +// which calls libbox.Setup once at process start; the registries set +// here are scoped to this peer's box instance so the two coexist +// without stomping on each other. +func defaultBuildBoxService(_ context.Context, options string) (boxService, error) { + bs, err := libbox.NewServiceWithContext(box.BaseContext(), options, nil) if err != nil { return nil, fmt.Errorf("libbox.NewServiceWithContext: %w", err) } diff --git a/peer/peer_test.go b/peer/peer_test.go index 9734e53a..944188e1 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -623,3 +623,46 @@ func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) { var _ portForwarder = (*fakeForwarder)(nil) var _ boxService = (*fakeBoxService)(nil) + +// TestDefaultBuildBoxService_DecodesSamizdatInbound is the regression net +// for the "missing inbound fields registry in context" failure that bit +// us live: defaultBuildBoxService used to call libbox.NewServiceWithContext +// with a fresh ctx that didn't have the lantern-box protocol registries +// (samizdat, reflex, …) plumbed in, so the JSON decoder couldn't resolve +// inbounds[0].type="samizdat" → libbox.NewServiceWithContext returned an +// error → applyPeerShare rolled the toggle back. The integration tests +// stub BuildBoxService entirely, so neither the libbox setup nor the +// samizdat decoder were exercised in CI. +// +// Calling defaultBuildBoxService directly with a minimal samizdat-inbound +// options JSON walks the actual decode path. If the registry is missing +// in the ctx that defaultBuildBoxService produces, libbox returns the +// "missing inbound fields registry" error and this test fails before any +// of the runtime cycle (rebuild, redeploy, toggle UI, dial-back) — what +// used to take a 5-minute round-trip is now a 0.1s test failure. +func TestDefaultBuildBoxService_DecodesSamizdatInbound(t *testing.T) { + // Minimal but complete samizdat inbound — every field that + // option.SamizdatInboundOptions's json tags require to round-trip. + // Values are placeholders; we don't run the box, just decode. + const opts = `{ + "inbounds": [{ + "type": "samizdat", + "tag": "samizdat-in", + "listen": "127.0.0.1", + "listen_port": 5698, + "private_key": "0000000000000000000000000000000000000000000000000000000000000000", + "short_ids": ["0000000000000000"], + "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIBhTCCASugAwIBAgIQCHOFXAcuEzPfyHK6LdwxwzAKBggqhkjOPQQDAjATMREw\nDwYDVQQKEwhJbnRlcm5ldDAeFw0yNjA1MDYwMDAwMDBaFw0yNzA1MDYwMDAwMDBa\nMBMxETAPBgNVBAoTCEludGVybmV0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE\nb6xQ7UDl11wL/8mZwLxrNqx6JJ+FczIw9V0a9Q3CYUYFGu5DzVyDUwmfVTZiQ+wR\nkQXjrkAwsOWK99JsM3R2bqNIMEYwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwEQYDVR0RBAowCIIGdGVzdC5xMAoGCCqG\nSM49BAMCA0kAMEYCIQCqhyaQaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaIh\nAOaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\n-----END CERTIFICATE-----\n", + "key_pem": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaoAoGCCqGSM49\nAwEHoUQDQgAEb6xQ7UDl11wL/8mZwLxrNqx6JJ+FczIw9V0a9Q3CYUYFGu5DzVyD\nUwmfVTZiQ+wRkQXjrkAwsOWK99JsM3R2bg==\n-----END EC PRIVATE KEY-----\n", + "masquerade_domain": "example.com" + }] + }` + + bs, err := defaultBuildBoxService(context.Background(), opts) + require.NoError(t, err, "defaultBuildBoxService must decode a samizdat inbound — "+ + "the lantern-box protocol registries have to be in ctx") + require.NotNil(t, bs) + // We never call Start; just verifying the decode path. Close drops + // any background structures libbox might have stood up. + _ = bs.Close() +} From 2f513a6289af2697720d9244c8a251b937cdc489 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 10:47:10 -0600 Subject: [PATCH 14/63] peer: forward common headers (notably X-Lantern-Config-Client-IP) on every peer endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peer/api.go was building requests with bare http.NewRequestWithContext, skipping the X-Lantern-Config-Client-IP / X-Lantern-User-Id / version header set that /config-new sends via common.NewRequestWithHeaders. That mattered for /peer/register specifically: the server's util.ClientIPWithAddr (lantern-cloud cmd/api/util/header.go:155-184) prefers X-Lantern-Config-Client-IP over X-Forwarded-For and RemoteAddr when resolving clientIP. With the header missing, the server fell back to whatever its X-Forwarded-For chain produced — potentially a different IP than the radiance-detected publicIP, leading the verifier to dial back to an address the peer's listener wasn't bound to. Switching to common.NewRequestWithHeaders makes peer endpoints consistent with /config-new's header set: - X-Lantern-Config-Client-IP (the key one for verify-dial targeting) - X-Lantern-App-Version, X-Lantern-Version, X-Lantern-Platform, X-Lantern-App, X-Lantern-User-Id, X-Lantern-Time-Zone, X-Lantern-Rand DeviceIDHeader is set by NewRequestWithHeaders from settings; we explicitly re-set it to a.deviceID afterward for parity with the prior behavior in case the two ever diverge. Adds TestAPI_ForwardsCommonHeaders which hits all four peer endpoints against a stub server and asserts each carries the expected headers (uses common.SetPublicIP / Cleanup to avoid leaking into other tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/api.go | 14 ++++++-- peer/peer_test.go | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/peer/api.go b/peer/api.go index 06fae20e..60a7f12a 100644 --- a/peer/api.go +++ b/peer/api.go @@ -10,6 +10,7 @@ import ( "io" "net/http" + "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/settings" ) @@ -102,14 +103,23 @@ func (a *API) do(ctx context.Context, method, path string, body, out any) error } reqBody = bytes.NewReader(buf) } - r, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, reqBody) + // Use common.NewRequestWithHeaders so peer endpoints carry the same + // header set as /config-new — most importantly X-Lantern-Config-Client-IP, + // which the server's util.ClientIPWithAddr prefers over X-Forwarded-For + // and RemoteAddr. Without it, register/verify can resolve a different + // IP than radiance has detected as the client's public IP, and the + // server's verifier dials an address the peer's listener isn't bound to. + r, err := common.NewRequestWithHeaders(ctx, method, a.baseURL+path, reqBody) if err != nil { return fmt.Errorf("build request: %w", err) } if body != nil { r.Header.Set("Content-Type", "application/json") } - r.Header.Set("X-Lantern-Device-Id", a.deviceID) + // NewRequestWithHeaders sets DeviceIDHeader from settings; override with + // the API's bound deviceID for parity with the prior behavior in case + // the two ever diverge. + r.Header.Set(common.DeviceIDHeader, a.deviceID) // Forward the same feature-override header that config/fetcher.go uses // for /config-new requests, so QA can flip on `peer_proxy` ahead of the // public-flag rollout via FeatureOverridesKey (RADIANCE_FEATURE_OVERRIDES). diff --git a/peer/peer_test.go b/peer/peer_test.go index 944188e1..99d9f9f1 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -666,3 +667,83 @@ func TestDefaultBuildBoxService_DecodesSamizdatInbound(t *testing.T) { // any background structures libbox might have stood up. _ = bs.Close() } + +// All four peer endpoints must carry the same standard header set as +// /config-new (X-Lantern-Config-Client-IP in particular). The server's +// util.ClientIPWithAddr prefers that header over X-Forwarded-For and +// RemoteAddr; without it, register/verify resolve a different IP than +// radiance has detected, and the server's verifier dials an address the +// peer's listener isn't bound to. +func TestAPI_ForwardsCommonHeaders(t *testing.T) { + const fakePublicIP = "198.51.100.7" + common.SetPublicIP(fakePublicIP) + t.Cleanup(func() { common.SetPublicIP("") }) + + type capture struct { + clientIP string + deviceID string + platform string + appName string + userAgent string + } + captured := make(map[string]capture) + var mu sync.Mutex + record := func(path string, r *http.Request) { + mu.Lock() + defer mu.Unlock() + captured[path] = capture{ + clientIP: r.Header.Get(common.ClientIPHeader), + deviceID: r.Header.Get(common.DeviceIDHeader), + platform: r.Header.Get(common.PlatformHeader), + appName: r.Header.Get(common.AppNameHeader), + userAgent: r.Header.Get("User-Agent"), + } + } + + mux := http.NewServeMux() + mux.HandleFunc("/peer/register", func(w http.ResponseWriter, r *http.Request) { + record("/peer/register", r) + _ = json.NewEncoder(w).Encode(RegisterResponse{ + RouteID: "00000000-0000-0000-0000-000000000123", + ServerConfig: `{}`, + HeartbeatIntervalSeconds: 60, + }) + }) + mux.HandleFunc("/peer/verify", func(w http.ResponseWriter, r *http.Request) { + record("/peer/verify", r) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { + record("/peer/heartbeat", r) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/peer/deregister", func(w http.ResponseWriter, r *http.Request) { + record("/peer/deregister", r) + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + api := NewAPI(srv.Client(), srv.URL, "test-device-id") + ctx := context.Background() + + _, err := api.Register(ctx, RegisterRequest{ExternalIP: "203.0.113.42", ExternalPort: 5698, InternalPort: 35698}) + require.NoError(t, err) + require.NoError(t, api.Verify(ctx, "00000000-0000-0000-0000-000000000123")) + require.NoError(t, api.Heartbeat(ctx, "00000000-0000-0000-0000-000000000123")) + require.NoError(t, api.Deregister(ctx, "00000000-0000-0000-0000-000000000123")) + + for _, path := range []string{"/peer/register", "/peer/verify", "/peer/heartbeat", "/peer/deregister"} { + mu.Lock() + c, ok := captured[path] + mu.Unlock() + require.True(t, ok, "no request captured for %s", path) + assert.Equal(t, fakePublicIP, c.clientIP, + "%s must forward radiance's detected public IP via %s "+ + "so server-side ClientIPWithAddr resolves the same IP it does for /config-new", + path, common.ClientIPHeader) + assert.Equal(t, "test-device-id", c.deviceID, "%s must carry %s", path, common.DeviceIDHeader) + assert.NotEmpty(t, c.platform, "%s must carry %s", path, common.PlatformHeader) + assert.NotEmpty(t, c.appName, "%s must carry %s", path, common.AppNameHeader) + } +} From a342889f260a6ede358e106e0e4150eea894e931 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 10:47:25 -0600 Subject: [PATCH 15/63] peer: add RADIANCE_PEER_EXTERNAL_PORT manual override for non-UPnP routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPnP / NAT-PMP / PCP discovery is silent or absent on a meaningful chunk of consumer routers — eero in particular ignores all three. For peers behind such routers, peer.Client.Start currently fails at the MapPort step with portforward.ErrNoPortForwarding even though the operator has perfectly valid manual port-forward rules in their router admin UI. Add an env-var escape hatch: when RADIANCE_PEER_EXTERNAL_PORT is set to a 1..65535 value, NewClient's default forwarder substitutes a manualPortForwarder that: - Returns the manual port unchanged for both internal and external sides of the Mapping (operator is responsible for matching the sing-box bind to the same port). - Returns "" from ExternalIP, letting peer_handler's "external_ip empty -> use observed" fall-through resolve the IP server-side. - Is a no-op for UnmapPort and StartRenewal (nothing to release; the manual rule is operator-managed). Invalid values (non-numeric, <1, >65535) log a warning and fall back to the default UPnP path so a typo doesn't silently disable peer share entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- common/env/env.go | 4 ++++ peer/peer.go | 50 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/common/env/env.go b/common/env/env.go index 5b2dcba2..f04de711 100644 --- a/common/env/env.go +++ b/common/env/env.go @@ -29,6 +29,10 @@ var ( Country _key = "RADIANCE_COUNTRY" FeatureOverrides _key = "RADIANCE_FEATURE_OVERRIDES" AppVersion _key = "RADIANCE_VERSION" + // PeerExternalPort, when set to a 1..65535 value, makes peer.Client.Start + // skip UPnP discovery and treat the value as a manually-forwarded port + // on the user's router (handy for eero / ISP CPE that don't expose UPnP). + PeerExternalPort _key = "RADIANCE_PEER_EXTERNAL_PORT" Testing _key = "RADIANCE_TESTING" diff --git a/peer/peer.go b/peer/peer.go index 8cda512c..286fcda0 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -7,16 +7,52 @@ import ( "fmt" "log/slog" "math/rand/v2" + "strconv" "sync" "time" "github.com/sagernet/sing-box/experimental/libbox" box "github.com/getlantern/lantern-box" + "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) +// manualPortForwarder satisfies the portForwarder interface without doing +// any UPnP work. Used when env.PeerExternalPort is set. +type manualPortForwarder struct{ port uint16 } + +func (m *manualPortForwarder) MapPort(_ context.Context, _ uint16, _ string) (*portforward.Mapping, error) { + return &portforward.Mapping{ + ExternalPort: m.port, + InternalPort: m.port, + Method: "manual-env", + }, nil +} +func (m *manualPortForwarder) UnmapPort(_ context.Context) error { return nil } +func (m *manualPortForwarder) StartRenewal(_ context.Context) {} +func (m *manualPortForwarder) ExternalIP(_ context.Context) (string, error) { + // Empty lets the server fill the observed IP in from r.RemoteAddr, + // matching peer_handler's "external_ip empty → use observed" path. + return "", nil +} + +// manualPort returns the parsed env.PeerExternalPort value, or 0 if unset +// or invalid. +func manualPort() uint16 { + raw := env.GetString(env.PeerExternalPort) + if raw == "" { + return 0 + } + p, err := strconv.Atoi(raw) + if err != nil || p < 1 || p > 65535 { + slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), "value", raw) + return 0 + } + return uint16(p) +} + // StatusEvent fires whenever the Client's session state changes — successful // Start, user Stop, or auto-Stop on a 404 heartbeat. type StatusEvent struct { @@ -112,6 +148,13 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { + // Manual override short-circuits UPnP discovery entirely; see + // env.PeerExternalPort. + if p := manualPort(); p != 0 { + slog.Info("peer client using manual port forward", + "port", p, "env", env.PeerExternalPort.String()) + return &manualPortForwarder{port: p}, nil + } // Explicitly return a nil interface on error — `return // portforward.NewForwarder(ctx)` collapses the (*Forwarder, error) // pair into a typed-nil interface on failure, which then panics @@ -463,12 +506,9 @@ func pickInternalPort() uint16 { // (samizdat, reflex, etc.) into the ctx so libbox can decode the // inbounds[0].type="samizdat" stanza coming back from /peer/register. // Without it the user's ctx is missing InboundOptionsRegistry and -// libbox returns "missing inbound fields registry in context" — the -// failure mode is silent in CI because the integration tests stub -// BuildBoxService entirely; only TestDefaultBuildBoxService_DecodesSamizdatInbound -// exercises the real decode path. +// libbox returns "missing inbound fields registry in context". // -// Runs in the same process as the user's VPN tunnel (vpn/tunnel.go), +// This runs in the same process as the user's VPN tunnel (vpn/tunnel.go), // which calls libbox.Setup once at process start; the registries set // here are scoped to this peer's box instance so the two coexist // without stomping on each other. From d420725b321f62ed8a16b7a89063b8675e52ccc1 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 10:47:38 -0600 Subject: [PATCH 16/63] backend: surface peer-share start failures + raise publicIP detection log to Info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-visibility tweaks that helped during peer-share testing and are worth keeping: 1. applyPeerShare(true) now logs the underlying Start error at Error level (and a paired success log at Info). Without this, a peer share toggle that fails server-side (4xx, UPnP miss, samizdat verify timeout) only surfaces via the IPC HTTP response — a layer the daemon log never sees, making post-hoc triage from the user's local logs much harder than necessary. 2. The "Detected public IP" log goes from Debug to Info, with the resolved IP added to the structured fields. publicIP is fetched exactly once per daemon lifetime; emitting it at Info gives operators a single line to compare against what lantern-cloud observed for that same client (visible in SigNoz traces) without needing to flip the global log level. No behavior change beyond the log lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/peer_share.go | 5 +++++ backend/radiance.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/peer_share.go b/backend/peer_share.go index 93edbe5f..bd3c17c9 100644 --- a/backend/peer_share.go +++ b/backend/peer_share.go @@ -55,12 +55,17 @@ func (r *LocalBackend) applyPeerShare(enabled bool) error { defer cancel() if enabled { if err := r.peerClient.Start(toggleCtx); err != nil { + // Surface the underlying Start error so operators can see it + // in the daemon log (UPnP failure, registration 4xx, etc.) + // rather than only via the IPC HTTP response. + slog.Error("peer share start failed", "err", err) if rbErr := settings.Patch(settings.Settings{settings.PeerShareEnabledKey: false}); rbErr != nil { slog.Error("peer share rollback failed after Start error", "start_err", err, "rollback_err", rbErr) } return fmt.Errorf("start peer share: %w", err) } + slog.Info("peer share start succeeded") return nil } if err := r.peerClient.Stop(toggleCtx); err != nil { diff --git a/backend/radiance.go b/backend/radiance.go index 4a63f07a..fe410318 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -213,7 +213,7 @@ func (r *LocalBackend) Start() { slog.Warn("Failed to get public IP", "error", err) } else { common.SetPublicIP(result.IP.String()) - slog.Debug("Detected public IP", "confidence", result.Confidence, "sources", result.Sources) + slog.Info("Detected public IP", "ip", result.IP.String(), "confidence", result.Confidence, "sources", result.Sources) } }() From 7768eca5a9dadd27c7bd69e125442d88c0109f2f Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 14:28:24 -0600 Subject: [PATCH 17/63] peer: address Copilot review on #466 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six clusters of fixes from the round-1 + round-2 Copilot passes: 1. peer/api.go: NewAPI doc now states the baseURL contract honestly — common.GetBaseURL returns either '.../v1' (stage) or '.../api/v1' (prod); the previous wording hard-coded the prod form and would mislead a future caller writing a test. 2. peer/peer_test.go: stub server registers under /v1/peer/* and newTestClient passes srv.server.URL+"/v1" as the baseURL. The bare URL the test had before would've masked a peer/api.go regression that double-prefixes the version segment. 3. peer/peer_test.go: /peer/verify handler decodes LifecycleRequest into srv.lastVerifyReq so tests can assert the route_id round-trips correctly. 4. peer/peer.go: defaultBuildBoxService no longer discards the caller's ctx. New boxRegistryCtx wraps the caller's ctx and falls back to box.BaseContext() on Value() lookups — preserves cancellation while keeping libbox's protocol-registry resolution working. 5. backend/radiance.go: 'Detected public IP' Info log no longer includes the IP itself. Lantern users in censored regions can't safely have their public IP in routinely-collected client logs; confidence + sources are enough for operator-side 'detection succeeded' triage and the IP is correlated server-side via traces. 6. backend/peer_share.go: slog calls use 'error' / 'start_error' / 'rollback_error' keys to match the backend-package convention (backend/radiance.go uses 'error' exclusively; the 'err' keys came from the peer package's own convention and don't fit here). New tests: - TestClient_Start_HappyPath now asserts verifyCount==1 and the route_id round-trips through /peer/verify. - TestClient_Start_VerifyFailureUnwinds: when verify returns 500, Start must unmap, close box, deregister, and return an error. All existing tests still pass under -race. Co-Authored-By: Claude Opus 4.7 --- backend/peer_share.go | 10 ++++---- backend/radiance.go | 6 ++++- peer/api.go | 9 ++++---- peer/peer.go | 24 ++++++++++++++++++-- peer/peer_test.go | 53 +++++++++++++++++++++++++++++++++++++++---- 5 files changed, 85 insertions(+), 17 deletions(-) diff --git a/backend/peer_share.go b/backend/peer_share.go index bd3c17c9..b488a106 100644 --- a/backend/peer_share.go +++ b/backend/peer_share.go @@ -58,10 +58,10 @@ func (r *LocalBackend) applyPeerShare(enabled bool) error { // Surface the underlying Start error so operators can see it // in the daemon log (UPnP failure, registration 4xx, etc.) // rather than only via the IPC HTTP response. - slog.Error("peer share start failed", "err", err) + slog.Error("peer share start failed", "error", err) if rbErr := settings.Patch(settings.Settings{settings.PeerShareEnabledKey: false}); rbErr != nil { slog.Error("peer share rollback failed after Start error", - "start_err", err, "rollback_err", rbErr) + "start_error", err, "rollback_error", rbErr) } return fmt.Errorf("start peer share: %w", err) } @@ -69,7 +69,7 @@ func (r *LocalBackend) applyPeerShare(enabled bool) error { return nil } if err := r.peerClient.Stop(toggleCtx); err != nil { - slog.Warn("peer share stop returned error (toggle still off)", "err", err) + slog.Warn("peer share stop returned error (toggle still off)", "error", err) } return nil } @@ -90,7 +90,7 @@ func (r *LocalBackend) resumePeerShareIfEnabled() { return } if err := r.applyPeerShare(true); err != nil { - slog.Warn("peer share auto-resume failed", "err", err) + slog.Warn("peer share auto-resume failed", "error", err) } }() } @@ -115,7 +115,7 @@ func (r *LocalBackend) closePeerClient() { stopCtx, cancel := context.WithTimeout(context.Background(), peerToggleTimeout) defer cancel() if err := r.peerClient.Stop(stopCtx); err != nil { - slog.Warn("peer share stop on backend close returned error", "err", err) + slog.Warn("peer share stop on backend close returned error", "error", err) } } diff --git a/backend/radiance.go b/backend/radiance.go index fe410318..f90476e2 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -213,7 +213,11 @@ func (r *LocalBackend) Start() { slog.Warn("Failed to get public IP", "error", err) } else { common.SetPublicIP(result.IP.String()) - slog.Info("Detected public IP", "ip", result.IP.String(), "confidence", result.Confidence, "sources", result.Sources) + // IP intentionally omitted — Lantern users in censored regions + // can't safely have their public IP in routinely-collected + // client logs. Confidence + sources are enough for operator + // triage; the actual IP is correlated server-side via traces. + slog.Info("Detected public IP", "confidence", result.Confidence, "sources", result.Sources) } }() diff --git a/peer/api.go b/peer/api.go index 60a7f12a..ca4b7ae9 100644 --- a/peer/api.go +++ b/peer/api.go @@ -49,10 +49,11 @@ type API struct { } // NewAPI constructs the client. baseURL must already include the API -// version prefix (matches common.GetBaseURL() which returns ".../api/v1"); -// per-endpoint paths are appended without re-adding /v1, mirroring every -// other radiance caller of common.GetBaseURL (config/fetcher.go, -// issue/issue.go, etc.). +// version path segment — common.GetBaseURL() returns ".../v1" (stage: +// api.staging.iantem.io/v1) or ".../api/v1" (prod: api.iantem.io/api/v1), +// depending on env. Per-endpoint paths are appended to baseURL without +// re-adding any version segment, mirroring every other radiance caller +// of common.GetBaseURL (config/fetcher.go, issue/issue.go, etc.). func NewAPI(httpClient *http.Client, baseURL, deviceID string) *API { return &API{httpClient: httpClient, baseURL: baseURL, deviceID: deviceID} } diff --git a/peer/peer.go b/peer/peer.go index 286fcda0..706a7c3e 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -508,14 +508,34 @@ func pickInternalPort() uint16 { // Without it the user's ctx is missing InboundOptionsRegistry and // libbox returns "missing inbound fields registry in context". // +// We wrap so libbox sees the caller's Deadline/Done (so a Stop-induced +// ctx cancel propagates to box internals) AND can still resolve the +// registry values from box.BaseContext via Value lookups. +// // This runs in the same process as the user's VPN tunnel (vpn/tunnel.go), // which calls libbox.Setup once at process start; the registries set // here are scoped to this peer's box instance so the two coexist // without stomping on each other. -func defaultBuildBoxService(_ context.Context, options string) (boxService, error) { - bs, err := libbox.NewServiceWithContext(box.BaseContext(), options, nil) +func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) { + bs, err := libbox.NewServiceWithContext(boxRegistryCtx{ctx}, options, nil) if err != nil { return nil, fmt.Errorf("libbox.NewServiceWithContext: %w", err) } return bs, nil } + +// boxRegistryCtx is a context wrapper that delegates Value() lookups to +// box.BaseContext() (where lantern-box's protocol registries live) while +// keeping the caller's Deadline/Done/Err for cancellation. Without this, +// passing box.BaseContext() directly to libbox would discard the +// caller's runCtx, leaving libbox internals running past Stop. +type boxRegistryCtx struct { + context.Context +} + +func (c boxRegistryCtx) Value(key any) any { + if v := c.Context.Value(key); v != nil { + return v + } + return box.BaseContext().Value(key) +} diff --git a/peer/peer_test.go b/peer/peer_test.go index 99d9f9f1..7992c616 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -152,6 +152,7 @@ type stubServer struct { heartbeatDeviceID atomic.Value // string deregisterDeviceID atomic.Value // string lastRegisterReq atomic.Value // RegisterRequest + lastVerifyReq atomic.Value // LifecycleRequest } func newStubServer(t *testing.T) *stubServer { @@ -168,8 +169,12 @@ func newStubServer(t *testing.T) *stubServer { HeartbeatIntervalSeconds: 60, }, } + // Mount handlers under /v1 so the test mirrors production's versioned + // baseURL (common.GetBaseURL returns ".../v1" or ".../api/v1"). Without + // this prefix, a regression in peer/api.go that accidentally re-adds + // a version segment would still pass the tests. mux := http.NewServeMux() - mux.HandleFunc("/peer/register", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/v1/peer/register", func(w http.ResponseWriter, r *http.Request) { s.registerCount.Add(1) s.registerDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) var req RegisterRequest @@ -181,16 +186,19 @@ func newStubServer(t *testing.T) *stubServer { } _ = json.NewEncoder(w).Encode(s.registerResp) }) - mux.HandleFunc("/peer/verify", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/v1/peer/verify", func(w http.ResponseWriter, r *http.Request) { s.verifyCount.Add(1) s.verifyDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) + var req LifecycleRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.lastVerifyReq.Store(req) if s.verifyStatus != http.StatusOK { http.Error(w, "verify failed", s.verifyStatus) return } w.WriteHeader(http.StatusOK) }) - mux.HandleFunc("/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/v1/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { s.heartbeatCount.Add(1) s.heartbeatDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) if s.heartbeatStatus != http.StatusOK { @@ -199,7 +207,7 @@ func newStubServer(t *testing.T) *stubServer { } w.WriteHeader(http.StatusOK) }) - mux.HandleFunc("/peer/deregister", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/v1/peer/deregister", func(w http.ResponseWriter, r *http.Request) { s.deregisterCount.Add(1) s.deregisterDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) if s.deregisterStatus != http.StatusOK { @@ -219,7 +227,10 @@ func newStubServer(t *testing.T) *stubServer { func newTestClient(t *testing.T, fwd portForwarder, box *fakeBoxService, srv *stubServer, opts ...func(*Config)) *Client { t.Helper() cfg := Config{ - API: NewAPI(srv.server.Client(), srv.server.URL, "test-device"), + // Production baseURL always includes a version segment. Mirror that + // here so the test catches any future regression in how peer/api.go + // composes endpoint URLs from baseURL. + API: NewAPI(srv.server.Client(), srv.server.URL+"/v1", "test-device"), NewForwarder: func(_ context.Context) (portForwarder, error) { return fwd, nil }, @@ -257,12 +268,44 @@ func TestClient_Start_HappyPath(t *testing.T) { assert.NotZero(t, req.ExternalPort) assert.NotZero(t, req.InternalPort) + // Start must call /peer/verify exactly once after bringing sing-box up, + // with the route_id returned from Register. Without this the server + // never confirms the peer is actually reachable from the public side. + assert.Equal(t, int64(1), srv.verifyCount.Load(), "Start must invoke /peer/verify") + assert.Equal(t, "test-device", srv.verifyDeviceID.Load()) + verifyReq := srv.lastVerifyReq.Load().(LifecycleRequest) + assert.Equal(t, "00000000-0000-0000-0000-000000000123", verifyReq.RouteID, + "/peer/verify must echo the route_id from Register") + status := c.CurrentStatus() assert.True(t, status.Active) assert.Equal(t, "203.0.113.42", status.ExternalIP) assert.Equal(t, "00000000-0000-0000-0000-000000000123", status.RouteID) } +// A server-side Verify failure means the listener we just brought up isn't +// reachable through the routed external endpoint. Start must unwind every +// resource it set up so we don't leave a registered route + open box + +// router mapping behind in that bad state. +func TestClient_Start_VerifyFailureUnwinds(t *testing.T) { + fwd := &fakeForwarder{externalIP: "203.0.113.42"} + box := &fakeBoxService{} + srv := newStubServer(t) + srv.verifyStatus = http.StatusInternalServerError + c := newTestClient(t, fwd, box, srv) + + err := c.Start(context.Background()) + require.Error(t, err) + assert.ErrorContains(t, err, "verify") + + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped(), "Verify failure must unmap the port forward") + assert.True(t, box.closed.Load(), "Verify failure must close the sing-box service") + assert.Equal(t, int64(1), srv.deregisterCount.Load(), + "Verify failure must deregister the route we just registered") + assert.Equal(t, int64(1), srv.verifyCount.Load(), "Verify was attempted exactly once") +} + func TestClient_Start_DoubleStartIsError(t *testing.T) { fwd := &fakeForwarder{} box := &fakeBoxService{} From c995f4bb9ae135bf9ff4992152fdd5ad49c0079c Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 20:52:56 -0600 Subject: [PATCH 18/63] peer: rotate samizdat credentials hourly (closes engineering#3437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently peer.Client builds the libbox inbound exactly once per Start and holds the same X25519 keypair / shortID / masquerade for the entire peer process lifetime — a leaked credential (logs, telemetry, support bundles, the route_id leakage in engineering#3440) remains usable for hours or days. This adds a credRotationLoop goroutine on a 1h tick. On each tick: 1. Re-register with lantern-cloud against the same (address, port) tuple — same router-side mapping, fresh server-side row, fresh samizdat creds. 2. Patch the new options for VPN bypass. 3. Build a new libbox service. 4. Close the old box (releases the listening port). 5. Start the new box (re-binds the same port with new creds). 6. Atomic swap of c.box, c.routeID. 7. Best-effort deregister of the prior route_id so the bandit stops handing the old (now-invalid) creds to clients within ~immediately rather than waiting up-to-TTL for the row to expire. Steps 4-5 leave a brief (~hundreds of ms) window where the port is unbound; samizdat clients see TCP RST and reconnect via the bandit. That's the trade-off vs. the security cost of holding the same cred for the peer process lifetime — caps blast radius from cred leakage to ~1h regardless of how long the peer has been running. Rotation is best-effort: a single failure logs and waits for the next tick. The current box and creds remain serving in the failure case so a transient register error doesn't kill the session. Config gains CredRotationInterval (defaults to peerCredRotationInterval = 1h) so tests drive the loop without a 1h sleep — see TestClient_RotatesCredentialsAtInterval. Co-Authored-By: Claude Opus 4.7 (1M context) --- peer/peer.go | 194 ++++++++++++++++++++++++++++++++++++++++++++-- peer/peer_test.go | 95 ++++++++++++++++++++++- 2 files changed, 281 insertions(+), 8 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index 500ae7a5..a41f5eec 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -59,14 +59,15 @@ type Status struct { } // Config plumbs in dependencies. Zero-valued fields fall back to production -// defaults; HeartbeatInterval and HeartbeatTimeout exist so tests can drive -// the loop without sleeping a full minute. +// defaults; HeartbeatInterval, HeartbeatTimeout, and CredRotationInterval +// exist so tests can drive the loops without sleeping a full minute / hour. type Config struct { - API *API - NewForwarder func(ctx context.Context) (portForwarder, error) - BuildBoxService boxFactory - HeartbeatInterval time.Duration - HeartbeatTimeout time.Duration + API *API + NewForwarder func(ctx context.Context) (portForwarder, error) + BuildBoxService boxFactory + HeartbeatInterval time.Duration + HeartbeatTimeout time.Duration + CredRotationInterval time.Duration } // Client orchestrates one peer-proxy session: open UPnP port → register with @@ -97,8 +98,38 @@ type Client struct { forwarder portForwarder box boxService routeID string + // externalPort / internalPort persist the port mapping picked at + // Start so the cred-rotation loop can re-register against the same + // (address, port) tuple without re-probing UPnP / re-mapping. The + // router-side mapping itself stays put across rotations; only the + // samizdat creds and route_id rotate. + externalPort uint16 + internalPort uint16 + // boxOptions is the fresh options string passed to BuildBoxService, + // kept for diagnostics and so the rotation path doesn't need to + // re-derive it from the (also-stored) box reference. + boxOptions string + // runCtx is captured here for the cred-rotation goroutine to bind + // the new libbox lifetime to the same context as the original Start. + // Stop's cancelRun() teardown still applies to the rebuilt box. + runCtx context.Context } +// peerCredRotationInterval bounds how long a leaked samizdat +// credential remains usable. At each tick the peer re-registers with +// lantern-cloud (new route_id, new keypair, new shortID), rebuilds the +// libbox service against the new options, and deregisters the prior +// route. Caps blast radius from credential leakage (logs, telemetry, +// memory dumps, the H2 leakage path in engineering#3440) to ~1h +// regardless of peer process lifetime. +// +// Cost per rotation: one API.Register + Deregister round trip, one +// libbox build + start + close cycle. Brief (~hundreds-of-ms) port- +// rebind window during the swap; samizdat clients see TCP RST and +// reconnect via the bandit. Acceptable trade-off vs. holding the same +// cred for the full peer process lifetime. +const peerCredRotationInterval = 1 * time.Hour + // peerCleanupTimeout caps how long Start's rollback path waits for // Deregister / UnmapPort. Cleanup uses a fresh Background context (not the // caller's ctx) so an already-canceled or expired Start ctx doesn't skip @@ -253,6 +284,10 @@ func (c *Client) Start(ctx context.Context) error { c.forwarder = fwd c.box = box c.routeID = regResp.RouteID + c.externalPort = mapping.ExternalPort + c.internalPort = mapping.InternalPort + c.boxOptions = options + c.runCtx = runCtx c.cancelRun = cancelRun c.runDone = runDone c.status = Status{ @@ -265,8 +300,14 @@ func (c *Client) Start(ctx context.Context) error { statusSnapshot := c.status c.mu.Unlock() + rotation := c.cfg.CredRotationInterval + if rotation == 0 { + rotation = peerCredRotationInterval + } + fwd.StartRenewal(runCtx) go c.heartbeatLoop(runCtx, heartbeat, runDone) + go c.credRotationLoop(runCtx, rotation) slog.Info("peer client started", "external_ip", externalIP, @@ -317,6 +358,10 @@ func (c *Client) Stop(ctx context.Context) error { c.forwarder = nil c.box = nil c.routeID = "" + c.externalPort = 0 + c.internalPort = 0 + c.boxOptions = "" + c.runCtx = nil c.status = Status{} c.mu.Unlock() @@ -408,6 +453,141 @@ func isNotRegistered(err error) bool { return errors.As(err, &apiErr) && apiErr.Status == 404 } +// credRotationLoop periodically rotates the peer's samizdat credentials +// (X25519 keypair, shortID, masquerade) by re-registering with +// lantern-cloud, rebuilding the libbox inbound, and deregistering the +// prior route. Caps blast radius from credential leakage to ~interval +// regardless of peer process lifetime — see peerCredRotationInterval. +// +// Closes done is the responsibility of heartbeatLoop; this loop just +// exits when ctx is cancelled. We deliberately don't add another close +// channel: heartbeatLoop's done already gates Stop, and rotation +// failures are non-fatal (log + retry next tick), so there's nothing +// the Stop path needs to wait on from this goroutine. +func (c *Client) credRotationLoop(ctx context.Context, interval time.Duration) { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := c.rotateCreds(ctx); err != nil { + // Don't kill the loop on a single failure — current + // box / route is still serving. Try again next tick. + slog.Warn("peer cred rotation failed; current creds remain in use", "err", err) + } + } + } +} + +// rotateCreds atomically swaps the peer's samizdat credentials. On +// success: a fresh route_id and keypair are in use, the libbox inbound +// has been rebuilt against the new options, the prior route is +// deregistered server-side, and the FlutterEvent stream sees no gap. +// On failure: the prior creds and box continue serving — rotation is +// best-effort. The router-side port mapping is preserved across the +// rotation; only the in-process samizdat state changes. +// +// Sequence: +// 1. Re-register with the same (externalIP, externalPort) as Start. +// 2. Patch the new server-supplied options for VPN bypass. +// 3. Build a new libbox service against the new options. +// 4. Close the old box (releases the listening port). +// 5. Start the new box (re-binds the same port, now with new creds). +// 6. Atomic swap: c.box, c.routeID, c.boxOptions point at the new box. +// 7. Best-effort deregister of the prior route_id so the bandit +// catalog stops handing the old (now-invalid) creds to clients. +// +// Steps 4-5 leave a brief (~hundreds of ms) window where the port +// isn't bound; samizdat clients see TCP RST and reconnect. Acceptable +// trade-off vs. the security cost of holding the same cred for the +// peer process lifetime. +func (c *Client) rotateCreds(ctx context.Context) error { + c.mu.Lock() + if !c.active { + c.mu.Unlock() + return errors.New("not active") + } + fwd := c.forwarder + extPort := c.externalPort + intPort := c.internalPort + oldRouteID := c.routeID + oldBox := c.box + c.mu.Unlock() + + if fwd == nil || oldBox == nil { + return errors.New("rotateCreds: client state inconsistent") + } + + externalIP, err := fwd.ExternalIP(ctx) + if err != nil { + return fmt.Errorf("get external ip: %w", err) + } + regResp, err := c.cfg.API.Register(ctx, RegisterRequest{ + ExternalIP: externalIP, + ExternalPort: extPort, + InternalPort: intPort, + }) + if err != nil { + return fmt.Errorf("re-register: %w", err) + } + options, err := ensurePeerOutboundsBypassVPN(regResp.ServerConfig) + if err != nil { + return fmt.Errorf("patch sing-box options: %w", err) + } + + c.mu.Lock() + runCtx := c.runCtx + c.mu.Unlock() + if runCtx == nil { + // Stop happened between the unlock above and here. Skip the + // build to avoid spinning up a libbox tied to a torn-down ctx. + // The new register row is harmless — server-side reaper will + // deprecate it after TTL since no heartbeat will arrive. + return errors.New("client stopped during rotation") + } + newBox, err := c.cfg.BuildBoxService(runCtx, options) + if err != nil { + return fmt.Errorf("build new sing-box: %w", err) + } + + // Close old, start new. Order matters — both want the same port. + // If newBox.Start fails after oldBox.Close, we lost the listener + // and the next heartbeat / rotation tick is the recovery point. + if closeErr := oldBox.Close(); closeErr != nil { + slog.Warn("close old box during rotation", "err", closeErr) + } + if err := newBox.Start(); err != nil { + // Catastrophic: port is now unbound. Leave c.box pointing at + // oldBox so a future Stop tries to close it (idempotent on + // already-closed); the next rotation tick will try again. + return fmt.Errorf("start new sing-box: %w", err) + } + + c.mu.Lock() + c.box = newBox + c.routeID = regResp.RouteID + c.boxOptions = options + c.status.RouteID = regResp.RouteID + c.mu.Unlock() + + // Deregister the prior route so the bandit stops handing the old + // (now-invalid) creds to clients. Best-effort: the prior row will + // expire from its TTL anyway, but explicit deregister cuts the + // stale-creds window from up-to-TTL down to ~immediately. + if err := c.cfg.API.Deregister(ctx, oldRouteID); err != nil { + slog.Warn("deregister prior route after rotation", + "err", err, "old_route_id", oldRouteID) + } + + slog.Info("peer cred rotation succeeded", + "new_route_id", regResp.RouteID, + "old_route_id", oldRouteID, + ) + return nil +} + // ensurePeerOutboundsBypassVPN guarantees the peer sing-box's outbound dials // bind to the physical interface rather than whatever the OS routing table // picks. Without this, when the user's own Lantern VPN is up its TUN holds diff --git a/peer/peer_test.go b/peer/peer_test.go index dd24c0ea..d1e635c1 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "sync" @@ -139,6 +140,10 @@ type stubServer struct { server *httptest.Server registerStatus int registerResp RegisterResponse + // registerRespFn lets a test return a different response per + // register call (e.g. cred-rotation tests need a fresh route_id + // each time). When non-nil, takes precedence over registerResp. + registerRespFn func() RegisterResponse heartbeatStatus int deregisterStatus int registerCount atomic.Int64 @@ -174,7 +179,11 @@ func newStubServer(t *testing.T) *stubServer { http.Error(w, "register failed", s.registerStatus) return } - _ = json.NewEncoder(w).Encode(s.registerResp) + resp := s.registerResp + if s.registerRespFn != nil { + resp = s.registerRespFn() + } + _ = json.NewEncoder(w).Encode(resp) }) mux.HandleFunc("/v1/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { s.heartbeatCount.Add(1) @@ -576,6 +585,90 @@ func TestAPIError_StringFormat(t *testing.T) { assert.Contains(t, e.Error(), "could not connect") } +// TestClient_RotatesCredentialsAtInterval pins the C2 fix from +// engineering#3437: the peer client must re-register and rebuild its +// libbox inbound on a schedule so a leaked credential's blast radius is +// bounded by CredRotationInterval rather than peer process lifetime. +// +// Drives a short rotation interval (50ms) and asserts: +// 1. Multiple registers happen (start + ≥2 rotations within 250ms). +// 2. Each rotation deregisters the prior route_id. +// 3. The peer's exposed RouteID changes — clients freshly assigned +// after a rotation see the new ID; the bandit catalog stops +// handing out the old one once Deregister lands. +// 4. Multiple distinct boxes were built (the rotation actually +// rebuilt libbox; not just a no-op). +// 5. The first box was closed (the old listener released its port). +func TestClient_RotatesCredentialsAtInterval(t *testing.T) { + fwd := &fakeForwarder{externalIP: "203.0.113.42"} + srv := newStubServer(t) + + // Each rotation needs a register response with a distinct + // route_id so we can verify the swap actually changed identifiers + // rather than re-registering the same id. + var registerSeq atomic.Int64 + srv.registerRespFn = func() RegisterResponse { + n := registerSeq.Add(1) + return RegisterResponse{ + RouteID: fmt.Sprintf("00000000-0000-0000-0000-00000000000%d", n), + ServerConfig: `{"inbounds": [{"type":"samizdat","tag":"samizdat-in"}]}`, + HeartbeatIntervalSeconds: 60, + } + } + + // Each BuildBoxService call gets a fresh fakeBoxService so we can + // see how many boxes were built and which ones got closed. + var ( + boxesMu sync.Mutex + boxes []*fakeBoxService + ) + c := newTestClient(t, fwd, &fakeBoxService{}, srv, func(cfg *Config) { + cfg.CredRotationInterval = 50 * time.Millisecond + // Long heartbeat so heartbeat ticks don't compete with the + // register/deregister counters that we're asserting on. + cfg.HeartbeatInterval = time.Hour + cfg.BuildBoxService = func(_ context.Context, options string) (boxService, error) { + b := &fakeBoxService{gotConfig: options} + boxesMu.Lock() + boxes = append(boxes, b) + boxesMu.Unlock() + return b, nil + } + }) + + require.NoError(t, c.Start(context.Background())) + t.Cleanup(func() { _ = c.Stop(context.Background()) }) + + // Wait for at least 2 rotations on top of the initial register. + require.Eventually(t, func() bool { + return srv.registerCount.Load() >= 3 + }, 1*time.Second, 25*time.Millisecond, + "expected ≥3 registers (initial + 2 rotations) within 1s; got %d", + srv.registerCount.Load()) + + // Each rotation deregisters the prior route — N rotations => + // N deregisters (initial register is not preceded by one). + rotations := srv.registerCount.Load() - 1 + assert.GreaterOrEqual(t, srv.deregisterCount.Load(), rotations-1, + "each rotation should deregister the prior route_id (got %d deregs vs %d rotations)", + srv.deregisterCount.Load(), rotations) + + // RouteID exposed via Status should reflect the latest rotation. + c.mu.Lock() + currentRouteID := c.routeID + c.mu.Unlock() + assert.NotEqual(t, "00000000-0000-0000-0000-000000000001", currentRouteID, + "current route_id should have advanced past the initial register") + + // Multiple boxes built; first one closed. + boxesMu.Lock() + defer boxesMu.Unlock() + require.GreaterOrEqual(t, len(boxes), 2, + "expected ≥2 libbox builds (initial + ≥1 rotation)") + assert.True(t, boxes[0].closed.Load(), + "first box should be closed by the first rotation") +} + // Subscribers (the IPC SSE handler in production) need both edges so the UI // can render fresh state without polling. func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) { From cdd286d943ba027d8ef824e7267749854ed81f2a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 15 May 2026 21:39:19 -0600 Subject: [PATCH 19/63] peer: refuse to start if launch_cfg lacks abuse-handling rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 Share My Connection abuse-handling lives entirely in the sing-box options that lantern-cloud sends back from /v1/peer/register. The peer client trusts that JSON and hands it straight to libbox. If a future regression in lantern-cloud/cmd/api/pcfg/samizdat.go silently shipped a launch_cfg without those rules, every newly-registered peer would become an open residential proxy until someone noticed — and the class of bug that triggers it is one missing function call in a file most reviewers don't routinely audit. validateAbuseRules adds defence-in-depth on the client side. After Register returns, before BuildBoxService is called, parse the JSON and assert: - route.rule_set declares all four abuse tags (geosite-malware, geoip-malware, geosite-phishing, geosite-cryptominers). - route.rules has a matching reject action for each tag — otherwise sing-box downloads the rule_set but never enforces it. - route.rules contains an RFC1918 reject (canary 10.0.0.0/8) and an SMTP-port reject (canary :25). One sentinel per static block in samizdat.go's peerEgressBlockRules, picked to detect "whole block was dropped" rather than fail on legitimate additions. The check is structural-only and permissive about JSON shape (sing- box marshals "default" rules in both inlined and nested forms; TestValidateAbuseRules_NestedDefaultForm asserts both work). It does NOT verify the .srs files at the rule_set URLs or that the URLs themselves are trustworthy — those are separate supply-chain concerns to track. errors.Join means a thoroughly-broken config surfaces every missing piece in one report so the deployer triaging "why won't my peer start?" doesn't have to fix-one-thing-find-the-next. Existing peer_test.go uses a minimal `{"inbounds":[…]}` fixture that would now fail the check. Migrated it to minimalValidLaunchCfg (shared with validate_test.go) — same shape as a real samizdat launch_cfg as far as the routing layer is concerned. --- peer/peer.go | 10 ++ peer/peer_test.go | 2 +- peer/validate.go | 214 ++++++++++++++++++++++++++++++++++++++++++ peer/validate_test.go | 183 ++++++++++++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 peer/validate.go create mode 100644 peer/validate_test.go diff --git a/peer/peer.go b/peer/peer.go index 500ae7a5..ca9cd4fa 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -207,6 +207,16 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("register with lantern-cloud: %w", err) } + // Defence-in-depth: refuse to start the box if the server-supplied + // launch_cfg is missing the expected abuse-handling rules. A + // server-side regression that silently shipped an open-proxy config + // would otherwise turn every peer in the field into one until the + // next deploy. The peer prefers failing to share over sharing + // unsafely. See validate.go for the exact checks. + if err := validateAbuseRules(regResp.ServerConfig); err != nil { + return fmt.Errorf("launch_cfg failed abuse-rule sanity check: %w", err) + } + // The peer's outbound traffic must bypass any TUN device the user's own // VPN may have installed — otherwise censored clients' traffic would // egress through the local user's Lantern proxy instead of their diff --git a/peer/peer_test.go b/peer/peer_test.go index dd24c0ea..24ff1373 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -159,7 +159,7 @@ func newStubServer(t *testing.T) *stubServer { deregisterStatus: http.StatusOK, registerResp: RegisterResponse{ RouteID: "00000000-0000-0000-0000-000000000123", - ServerConfig: `{"inbounds": [{"type":"samizdat","tag":"samizdat-in"}]}`, + ServerConfig: minimalValidLaunchCfg, HeartbeatIntervalSeconds: 60, }, } diff --git a/peer/validate.go b/peer/validate.go new file mode 100644 index 00000000..e9d34434 --- /dev/null +++ b/peer/validate.go @@ -0,0 +1,214 @@ +package peer + +import ( + "encoding/json" + "errors" + "fmt" +) + +// abuseRuleSetTags is the canonical list of abuse rule_set tags that the +// peer launch_cfg MUST carry. Mirrors abuseTags in +// lantern-cloud/cmd/api/pcfg/samizdat.go. If samizdat.go grows or +// renames a tag, this list grows with it — the test in +// lantern-cloud asserts the server side; this list asserts the client +// side sees the same shape after registration. +var abuseRuleSetTags = []string{ + "geosite-malware", + "geoip-malware", + "geosite-phishing", + "geosite-cryptominers", +} + +// rfc1918CanaryCIDR and smtpCanaryPort are sentinel values that, if +// missing from the launch_cfg's reject rules, indicate the static +// peerEgressBlockRules block in samizdat.go was dropped or mutated. +// We pick one IP-CIDR and one port from each block as a low-cost smoke +// test; a full structural check would be brittle to upstream additions. +const ( + rfc1918CanaryCIDR = "10.0.0.0/8" + smtpCanaryPort = float64(25) +) + +// validateAbuseRules is a defence-in-depth check on the sing-box +// options returned by /v1/peer/register. The server is supposed to +// embed a set of route.rule_set + route.rules entries that block the +// peer from forwarding traffic to known-malicious destinations, +// RFC1918 CIDRs, and abuse-prone ports. Those rules live in +// lantern-cloud/cmd/api/pcfg/samizdat.go. +// +// If a future regression in that server-side file ships a launch_cfg +// without those rules, every newly-registered peer would silently turn +// into an open residential proxy until someone noticed. This validator +// blocks Start before libbox runs an unsafe config; the peer prefers +// to fail to share at all rather than share unsafely. +// +// The check is structural-only — it confirms the expected rule_set +// tags appear in both route.rule_set and route.rules (as a reject +// action), plus two canary entries from the static reject block. It +// does NOT verify the .srs files at the rule_set URLs are uncorrupted +// or that the URLs themselves are trustworthy; those are separate +// supply-chain concerns tracked in engineering#TODO. +func validateAbuseRules(optionsJSON string) error { + var raw map[string]any + if err := json.Unmarshal([]byte(optionsJSON), &raw); err != nil { + return fmt.Errorf("parse launch_cfg JSON: %w", err) + } + route, ok := raw["route"].(map[string]any) + if !ok { + return errors.New("launch_cfg is missing route block — peer would have no abuse blocking at all") + } + + var errs []error + if err := validateAbuseRuleSetTags(route); err != nil { + errs = append(errs, err) + } + if err := validateAbuseRejectRules(route); err != nil { + errs = append(errs, err) + } + if err := validateStaticRejectCanaries(route); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +// validateAbuseRuleSetTags asserts every entry in abuseRuleSetTags is +// declared in route.rule_set. Missing entries mean sing-box won't even +// download the abuse list, so no destination check ever happens. +func validateAbuseRuleSetTags(route map[string]any) error { + rsList, _ := route["rule_set"].([]any) + got := map[string]bool{} + for _, rs := range rsList { + rsMap, ok := rs.(map[string]any) + if !ok { + continue + } + if tag, _ := rsMap["tag"].(string); tag != "" { + got[tag] = true + } + } + var missing []string + for _, want := range abuseRuleSetTags { + if !got[want] { + missing = append(missing, want) + } + } + if len(missing) > 0 { + return fmt.Errorf("route.rule_set is missing abuse tags: %v (peer would not block matching destinations)", missing) + } + return nil +} + +// validateAbuseRejectRules asserts every abuseRuleSetTags entry also +// has a matching reject rule in route.rules. A rule_set without a +// matching reject is a no-op — sing-box downloads the list and does +// nothing with it. +func validateAbuseRejectRules(route map[string]any) error { + rules, _ := route["rules"].([]any) + rejectedTags := map[string]bool{} + for _, r := range rules { + body := ruleBody(r) + if body == nil { + continue + } + if action, _ := body["action"].(string); action != "reject" { + continue + } + for _, t := range asStringSlice(body["rule_set"]) { + rejectedTags[t] = true + } + } + var missing []string + for _, want := range abuseRuleSetTags { + if !rejectedTags[want] { + missing = append(missing, want) + } + } + if len(missing) > 0 { + return fmt.Errorf("route.rules has no reject action for abuse tags: %v (rule_sets would download but not block)", missing) + } + return nil +} + +// validateStaticRejectCanaries spot-checks that the static +// destination-based reject rules (RFC1918 CIDRs + abuse ports) are +// present. Picks one canary from each block rather than asserting the +// full set so legitimate additions in samizdat.go don't break this +// check. +func validateStaticRejectCanaries(route map[string]any) error { + rules, _ := route["rules"].([]any) + gotRFC1918 := false + gotSMTP := false + for _, r := range rules { + body := ruleBody(r) + if body == nil { + continue + } + if action, _ := body["action"].(string); action != "reject" { + continue + } + for _, cidr := range asStringSlice(body["ip_cidr"]) { + if cidr == rfc1918CanaryCIDR { + gotRFC1918 = true + } + } + for _, p := range asFloatSlice(body["port"]) { + if p == smtpCanaryPort { + gotSMTP = true + } + } + } + var missing []string + if !gotRFC1918 { + missing = append(missing, fmt.Sprintf("RFC1918 reject (canary %s)", rfc1918CanaryCIDR)) + } + if !gotSMTP { + missing = append(missing, fmt.Sprintf("SMTP-port reject (canary :%d)", int(smtpCanaryPort))) + } + if len(missing) > 0 { + return fmt.Errorf("route.rules is missing static abuse blocks: %v", missing) + } + return nil +} + +// ruleBody returns the field-bearing inner object of a sing-box +// route Rule. sing-box marshals "default" rules in two equivalent +// shapes: inlined at the top level (no "default" wrapper) or nested +// under "default". We accept both. +func ruleBody(r any) map[string]any { + m, ok := r.(map[string]any) + if !ok { + return nil + } + if nested, ok := m["default"].(map[string]any); ok { + return nested + } + return m +} + +func asStringSlice(v any) []string { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, x := range arr { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out +} + +func asFloatSlice(v any) []float64 { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]float64, 0, len(arr)) + for _, x := range arr { + if f, ok := x.(float64); ok { + out = append(out, f) + } + } + return out +} diff --git a/peer/validate_test.go b/peer/validate_test.go new file mode 100644 index 00000000..723ec0ef --- /dev/null +++ b/peer/validate_test.go @@ -0,0 +1,183 @@ +package peer + +import ( + "strings" + "testing" +) + +// minimalValidLaunchCfg returns a launch_cfg JSON that passes +// validateAbuseRules: the four abuse rule_set tags from +// lantern-cloud's samizdat.go (each as a "remote" rule_set + a +// matching reject rule), plus one RFC1918 and one SMTP canary in +// reject rules. Shared by peer_test.go's stubServer so the existing +// Start-path tests do not regress on the new check. +const minimalValidLaunchCfg = `{ + "inbounds":[{"type":"samizdat","tag":"samizdat-in"}], + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware","format":"binary","url":"https://example/geosite-malware.srs","download_detour":"direct"}, + {"type":"remote","tag":"geoip-malware","format":"binary","url":"https://example/geoip-malware.srs","download_detour":"direct"}, + {"type":"remote","tag":"geosite-phishing","format":"binary","url":"https://example/geosite-phishing.srs","download_detour":"direct"}, + {"type":"remote","tag":"geosite-cryptominers","format":"binary","url":"https://example/geosite-cryptominers.srs","download_detour":"direct"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"]}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","169.254.0.0/16","::1/128","fc00::/7","fe80::/10"]}, + {"action":"reject","port":[25,465,587,2525,6660,6661,6662,6663,6664,6665,6666,6667,6668,6669,6697]} + ] + } +}` + +func TestValidateAbuseRules_HappyPath(t *testing.T) { + if err := validateAbuseRules(minimalValidLaunchCfg); err != nil { + t.Fatalf("expected canonical launch_cfg to pass, got: %v", err) + } +} + +func TestValidateAbuseRules_NestedDefaultForm(t *testing.T) { + // sing-box may marshal "default" rules either inlined or nested + // under a "default" key. validateAbuseRules must accept both — + // otherwise a future libbox version change would silently break + // the check. + const nested = `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"type":"default","default":{"action":"reject","rule_set":["geosite-malware"]}}, + {"type":"default","default":{"action":"reject","rule_set":["geoip-malware"]}}, + {"type":"default","default":{"action":"reject","rule_set":["geosite-phishing"]}}, + {"type":"default","default":{"action":"reject","rule_set":["geosite-cryptominers"]}}, + {"type":"default","default":{"action":"reject","ip_cidr":["10.0.0.0/8"]}}, + {"type":"default","default":{"action":"reject","port":[25]}} + ] + } + }` + if err := validateAbuseRules(nested); err != nil { + t.Fatalf("nested default form should pass, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRouteBlock(t *testing.T) { + err := validateAbuseRules(`{"inbounds":[]}`) + if err == nil { + t.Fatal("expected error when route block is absent") + } + if !strings.Contains(err.Error(), "route block") { + t.Errorf("error should mention route block, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRuleSetTag(t *testing.T) { + // Drop geosite-phishing from the rule_set list. The reject rule + // for it can stay; the check should still flag the missing tag + // because the reject is a no-op without the rule_set. + bad := strings.Replace(minimalValidLaunchCfg, + `{"type":"remote","tag":"geosite-phishing","format":"binary","url":"https://example/geosite-phishing.srs","download_detour":"direct"},`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when an abuse tag is missing from route.rule_set") + } + if !strings.Contains(err.Error(), "geosite-phishing") { + t.Errorf("error should name the missing tag, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRejectRule(t *testing.T) { + // Keep all rule_sets but drop one reject rule. sing-box will + // download the list but never enforce it. + bad := strings.Replace(minimalValidLaunchCfg, + `{"action":"reject","rule_set":["geosite-cryptominers"]},`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when an abuse tag has no reject rule") + } + if !strings.Contains(err.Error(), "geosite-cryptominers") { + t.Errorf("error should name the unrejected tag, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRFC1918Canary(t *testing.T) { + // Strip the RFC1918 reject rule. SMTP block stays — we want to + // see the RFC1918-specific error message. + bad := strings.Replace(minimalValidLaunchCfg, + `{"action":"reject","ip_cidr":["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","169.254.0.0/16","::1/128","fc00::/7","fe80::/10"]},`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when RFC1918 reject is missing") + } + if !strings.Contains(err.Error(), "RFC1918") { + t.Errorf("error should mention RFC1918, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingSMTPCanary(t *testing.T) { + // Drop the SMTP/IRC port-reject rule. Removes the preceding + // comma too so the resulting JSON is still well-formed (the + // port-reject is the last entry in the rules array). + bad := strings.Replace(minimalValidLaunchCfg, + `, + {"action":"reject","port":[25,465,587,2525,6660,6661,6662,6663,6664,6665,6666,6667,6668,6669,6697]}`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when SMTP port reject is missing") + } + if !strings.Contains(err.Error(), "SMTP") { + t.Errorf("error should mention SMTP, got: %v", err) + } +} + +func TestValidateAbuseRules_NonRejectRulesIgnored(t *testing.T) { + // A rule_set with action "route" (not reject) should NOT count + // — sing-box would forward those flows to a named outbound + // instead of dropping them. validateAbuseRules must demand the + // reject action specifically. + bad := strings.Replace(minimalValidLaunchCfg, + `{"action":"reject","rule_set":["geosite-malware"]},`, + `{"action":"route","outbound":"direct","rule_set":["geosite-malware"]},`, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when abuse tag has 'route' action instead of 'reject'") + } + if !strings.Contains(err.Error(), "geosite-malware") { + t.Errorf("error should name the wrongly-actioned tag, got: %v", err) + } +} + +func TestValidateAbuseRules_AllErrorsReported(t *testing.T) { + // errors.Join means a thoroughly-broken config should surface + // all the missing pieces in one report. Operators triaging + // "why is my peer refusing to start?" deserve a complete + // picture, not a fix-one-thing-find-the-next loop. + err := validateAbuseRules(`{"route":{}}`) + if err == nil { + t.Fatal("expected error for empty route block") + } + msg := err.Error() + for _, want := range []string{"abuse tags", "RFC1918", "SMTP"} { + if !strings.Contains(msg, want) { + t.Errorf("combined error should mention %q, got: %v", want, err) + } + } +} + +func TestValidateAbuseRules_BadJSON(t *testing.T) { + err := validateAbuseRules(`{not valid json`) + if err == nil { + t.Fatal("expected error for malformed JSON") + } + if !strings.Contains(err.Error(), "parse launch_cfg JSON") { + t.Errorf("error should mention JSON parse failure, got: %v", err) + } +} From 707030c79aa4d4f653cc4f3f38faf5e1d9ffc2d1 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 14:57:47 -0600 Subject: [PATCH 20/63] peer: address Copilot review on #466 (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md:13-17 forbids code-location references in comments. Round-1 fixes had reintroduced several. Rewrote each to describe the contract or invariant directly without naming files: - peer/peer.go:37 (manualPortForwarder.ExternalIP) — drop reference to the server's peer_handler. - peer/peer.go:152 (NewClient manual-override branch) — drop the 'see env.PeerExternalPort' pointer; the manualPort() call site is self- describing. - peer/peer.go:518 (defaultBuildBoxService) — drop the explicit vpn/tunnel.go path; the 'same process as the user's main VPN tunnel' framing carries the invariant without naming the source file. - peer/api.go:56 (NewAPI doc) — drop the 'mirroring config/fetcher.go, issue/issue.go' tail and the hard-coded host names. The old comment also named the wrong prod host: BaseURL is df.iantem.io/api/v1, not api.iantem.io/api/v1, so the inaccuracy is fixed too. - peer/peer_test.go:175 + :232 — drop the peer/api.go references; the 'regression in URL composition' framing is what matters. Also added test coverage for RADIANCE_PEER_EXTERNAL_PORT: - TestManualPort exercises parsing across unset / valid mid-range / valid 1 + 65535 boundaries / non-numeric / 0 / negative / above- uint16 / way-above-uint16. All non-positive and out-of-range values collapse to 0 (the 'use UPnP discovery' signal). - TestManualPortForwarder exercises the full portForwarder contract: MapPort returns external==internal port + 'manual-env' method, UnmapPort and StartRenewal are no-ops, ExternalIP returns empty (server substitutes observed IP). Co-Authored-By: Claude Opus 4.7 --- peer/api.go | 9 +++---- peer/peer.go | 15 ++++++------ peer/peer_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/peer/api.go b/peer/api.go index ca4b7ae9..694d724b 100644 --- a/peer/api.go +++ b/peer/api.go @@ -49,11 +49,10 @@ type API struct { } // NewAPI constructs the client. baseURL must already include the API -// version path segment — common.GetBaseURL() returns ".../v1" (stage: -// api.staging.iantem.io/v1) or ".../api/v1" (prod: api.iantem.io/api/v1), -// depending on env. Per-endpoint paths are appended to baseURL without -// re-adding any version segment, mirroring every other radiance caller -// of common.GetBaseURL (config/fetcher.go, issue/issue.go, etc.). +// version path segment — common.GetBaseURL() returns an env-specific URL +// suffixed with either ".../v1" or ".../api/v1". Per-endpoint paths are +// appended to baseURL without re-adding any version segment, matching +// how every other radiance caller of common.GetBaseURL composes URLs. func NewAPI(httpClient *http.Client, baseURL, deviceID string) *API { return &API{httpClient: httpClient, baseURL: baseURL, deviceID: deviceID} } diff --git a/peer/peer.go b/peer/peer.go index 706a7c3e..73222e10 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -33,8 +33,9 @@ func (m *manualPortForwarder) MapPort(_ context.Context, _ uint16, _ string) (*p func (m *manualPortForwarder) UnmapPort(_ context.Context) error { return nil } func (m *manualPortForwarder) StartRenewal(_ context.Context) {} func (m *manualPortForwarder) ExternalIP(_ context.Context) (string, error) { - // Empty lets the server fill the observed IP in from r.RemoteAddr, - // matching peer_handler's "external_ip empty → use observed" path. + // An empty external IP signals the server to use the address it + // observed on the inbound request — when the user has supplied a + // manual port but no WAN IP, the server's view is the right answer. return "", nil } @@ -148,8 +149,6 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { - // Manual override short-circuits UPnP discovery entirely; see - // env.PeerExternalPort. if p := manualPort(); p != 0 { slog.Info("peer client using manual port forward", "port", p, "env", env.PeerExternalPort.String()) @@ -512,10 +511,10 @@ func pickInternalPort() uint16 { // ctx cancel propagates to box internals) AND can still resolve the // registry values from box.BaseContext via Value lookups. // -// This runs in the same process as the user's VPN tunnel (vpn/tunnel.go), -// which calls libbox.Setup once at process start; the registries set -// here are scoped to this peer's box instance so the two coexist -// without stomping on each other. +// Lives in the same process as the user's main VPN tunnel, which has +// already invoked libbox.Setup at process start. The registries set +// here are scoped to this peer's box instance via context values, so +// the two coexist without stomping on each other. func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) { bs, err := libbox.NewServiceWithContext(boxRegistryCtx{ctx}, options, nil) if err != nil { diff --git a/peer/peer_test.go b/peer/peer_test.go index 7992c616..e465d2ea 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -170,9 +170,9 @@ func newStubServer(t *testing.T) *stubServer { }, } // Mount handlers under /v1 so the test mirrors production's versioned - // baseURL (common.GetBaseURL returns ".../v1" or ".../api/v1"). Without - // this prefix, a regression in peer/api.go that accidentally re-adds - // a version segment would still pass the tests. + // baseURL (common.GetBaseURL returns a URL ending in /v1 or /api/v1). + // Without this prefix, a regression that accidentally re-adds the + // version segment when composing endpoint URLs would still pass. mux := http.NewServeMux() mux.HandleFunc("/v1/peer/register", func(w http.ResponseWriter, r *http.Request) { s.registerCount.Add(1) @@ -228,8 +228,8 @@ func newTestClient(t *testing.T, fwd portForwarder, box *fakeBoxService, srv *st t.Helper() cfg := Config{ // Production baseURL always includes a version segment. Mirror that - // here so the test catches any future regression in how peer/api.go - // composes endpoint URLs from baseURL. + // here so the test catches any future regression in how endpoint + // URLs are composed from baseURL. API: NewAPI(srv.server.Client(), srv.server.URL+"/v1", "test-device"), NewForwarder: func(_ context.Context) (portForwarder, error) { return fwd, nil @@ -627,6 +627,58 @@ func TestPickInternalPort_InRange(t *testing.T) { } } +// manualPort parses the RADIANCE_PEER_EXTERNAL_PORT env var. Unset, empty, +// non-numeric, and out-of-range values all collapse to 0, which the +// NewClient default factory treats as "no override → use UPnP discovery". +// Only a 1..65535 value selects the manual path. +func TestManualPort(t *testing.T) { + tests := []struct { + name string + env string + want uint16 + }{ + {"unset", "", 0}, + {"valid mid-range", "5698", 5698}, + {"valid low boundary", "1", 1}, + {"valid high boundary", "65535", 65535}, + {"non-numeric", "abc", 0}, + {"zero", "0", 0}, + {"negative", "-5", 0}, + {"above uint16", "65536", 0}, + {"way above uint16", "99999", 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("RADIANCE_PEER_EXTERNAL_PORT", tc.env) + assert.Equal(t, tc.want, manualPort()) + }) + } +} + +// manualPortForwarder must satisfy the portForwarder contract: MapPort +// returns a Mapping using the configured port for both internal and +// external (no rewrite — that's the user's responsibility), UnmapPort +// and StartRenewal are no-ops, and ExternalIP returns "" so the server +// substitutes the IP it observed on the request. +func TestManualPortForwarder(t *testing.T) { + f := &manualPortForwarder{port: 5698} + + m, err := f.MapPort(context.Background(), 30001, "ignored") + require.NoError(t, err) + assert.Equal(t, uint16(5698), m.ExternalPort) + assert.Equal(t, uint16(5698), m.InternalPort, "external==internal — user mapped them themselves") + assert.Equal(t, "manual-env", m.Method) + + require.NoError(t, f.UnmapPort(context.Background()), "UnmapPort is a no-op for manual forwarders") + + // StartRenewal must not panic or block. + f.StartRenewal(context.Background()) + + ip, err := f.ExternalIP(context.Background()) + require.NoError(t, err) + assert.Empty(t, ip, "empty ip signals server to use observed source address") +} + func TestAPIError_StringFormat(t *testing.T) { e := &APIError{Status: 422, Body: "could not connect to peer port"} assert.Contains(t, e.Error(), "422") From 5223e6dd2616d2613f2bc20fea1e338f35ec9434 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:37:02 -0600 Subject: [PATCH 21/63] peer: emit ConnectionEvent on samizdat accept/close Plumb lantern-box's peerconn listener registry through to the radiance event bus so consumers (Flutter globe view, future abuse aggregation) can subscribe to a per-connection accept/close stream. Listener is registered after libbox.Start so the box's accept loop is already serving when notifications start flowing; cleared on Stop and in the Start rollback path so post-teardown callbacks land on a no-op rather than emitting events to a torn-down consumer. Source field carries the remote "ip:port" string verbatim from M.Socksaddr.String(); consumers extract the IP for geo-lookup or rate-limit attribution. Pinned to local lantern-box via a replace directive while the peerconn package is in flight; remove once lantern-box tags a release. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit d4fc0cb240b8ec83878ebc605a25eea903c1fece) --- go.mod | 4 ++++ go.sum | 14 ++++++++++++++ peer/peer.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/go.mod b/go.mod index 228186c0..4f8586bd 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,10 @@ module github.com/getlantern/radiance go 1.26.2 +// Local while peerconn listener registry is in flight; remove once +// lantern-box tags a release that includes tracker/peerconn. +replace github.com/getlantern/lantern-box => ../lantern-box + replace github.com/sagernet/sing => github.com/getlantern/sing v0.7.18-lantern replace github.com/sagernet/sing-box => github.com/getlantern/sing-box-minimal v1.12.22-lantern diff --git a/go.sum b/go.sum index 5fd5538f..be09ac43 100644 --- a/go.sum +++ b/go.sum @@ -246,12 +246,26 @@ github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2y github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A= github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/47Hfk7FjW6yaD+1h6kO7C/iauV0DkVia/bXU= github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= +<<<<<<< HEAD github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab h1:PitYhTvo3oHRKYl4pVAoOIN8bhM+Bw+JBWncMglvHSg= github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= github.com/getlantern/lantern-box v0.0.86 h1:myJa+Crg/oMgqSFhX7DOox4XcVIx8VFiPnkel8x8YT4= github.com/getlantern/lantern-box v0.0.86/go.mod h1:BVXPyEicSu7m4nQY1OHPkOZNj87M7sYrzmY9AgyiPkc= +======= +<<<<<<< HEAD +github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03 h1:dUTN7mnTTBcSvsURNs1rTlyKrD1uXUEPqxEZDfl+hb4= +github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= +github.com/getlantern/lantern-box v0.0.84 h1:y+nezmu0LZDlzcS2A4oKDu3f1UTFAgA24vT1htvEiX0= +github.com/getlantern/lantern-box v0.0.84/go.mod h1:6SO1p22tAq9y8JLjNnAbr4/GZ4VjmlcQGYn0qF4aD/k= +>>>>>>> cd64ed8 (peer: emit ConnectionEvent on samizdat accept/close) github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395 h1:grfGavAUp2E9w9ZoJuM3FyWyQ0sCJ64V4ZMKtZKRqTc= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= +======= +github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 h1:P5pkaBGxWOGBn7bKzjzdln/ro+ShG1RUbOuy+7pSzXE= +github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= +github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= +github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= +>>>>>>> 231462b (peer: emit ConnectionEvent on samizdat accept/close) github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y= github.com/getlantern/osversion v0.0.0-20240418205916-2e84a4a4e175 h1:JWH5BB2o0eAeGs0tZnFPpQGx+nMIo/WmxKnj2hnGjgE= diff --git a/peer/peer.go b/peer/peer.go index 73222e10..c77051d3 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -14,6 +14,7 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" box "github.com/getlantern/lantern-box" + "github.com/getlantern/lantern-box/tracker/peerconn" "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" @@ -61,6 +62,19 @@ type StatusEvent struct { Status Status `json:"status"` } +// ConnectionEvent fires every time a remote client opens or closes a +// samizdat session against the local peer's inbound. Source carries the +// remote "ip:port" string; consumers (the globe view, abuse aggregation) +// extract the IP for geo-lookup or rate-limit attribution. +// +// State +1 on accept, -1 on close +// Source remote peer "ip:port" +type ConnectionEvent struct { + events.Event + State int `json:"state"` + Source string `json:"source"` +} + // Port range chosen to minimize collision risk on the typical home network, // not to guarantee one. 30000–50000 sits above the well-known/system range // (0–1023) and above the ports most services use by default (web/dev/dbs @@ -213,6 +227,11 @@ func (c *Client) Start(ctx context.Context) error { // registered route + router rule. cleanupCtx, cancel := context.WithTimeout(context.Background(), peerCleanupTimeout) defer cancel() + // Always clear the connection listener on rollback. The listener is + // only Set on the success path, so this is a no-op if Start failed + // before reaching it — but cheap insurance against a future re-order + // that registers earlier. + peerconn.SetListener(nil) if box != nil { _ = box.Close() } @@ -276,6 +295,16 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("start sing-box: %w", err) } + // Forward inbound accept/close events from lantern-box's samizdat + // inbound to the radiance event bus, so consumers (the Flutter globe, + // future abuse aggregation) get a per-connection stream. Listener is + // process-wide single-active; cleared on Stop. Register BEFORE Verify + // so the verify-dial connection itself emits an event — the listener + // must be in place by the time the server dials back. + peerconn.SetListener(func(state int, source string) { + events.Emit(ConnectionEvent{State: state, Source: source}) + }) + // Now that sing-box is listening with the just-built creds, ask the // server to dial back through them. Splitting verify out of Register // into this explicit follow-up avoids the chicken-and-egg where the @@ -374,6 +403,11 @@ func (c *Client) Stop(ctx context.Context) error { c.status = Status{} c.mu.Unlock() + // Clear the connection listener BEFORE box.Close so any in-flight + // accept-loop callbacks land on a no-op rather than emit ConnectionEvents + // after the consumer side has already torn down its subscription. + peerconn.SetListener(nil) + cancel() <-done From ee8c93faf7152056878a8765562a1a836dc71911 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:41:52 -0600 Subject: [PATCH 22/63] peer: serve live connection snapshot on 127.0.0.1:17099/peer/connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a localhost HTTP endpoint exposing the active samizdat connection set as JSON, fed by the lantern-box peerconn listener registered when peer.Client.Start succeeds. Replaces the planned full Go→FFI→Dart event channel for the prototype with poll-driven Dart consumption — much smaller surface, same data shape, swap with a streaming FFI events path later without changing the Dart side. Loopback-only: net.Listen 127.0.0.1 enforces it at the kernel level, plus a defense-in-depth host check on each request in case someone later misconfigures RADIANCE_PEER_STATS_ADDR to a non-loopback bind. The endpoint reveals connected client IPs which we don't want surfaced beyond the local machine. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 48e0f6f197cf0d0b3017b35161c7f52bc8ebb549) --- peer/connstats.go | 199 ++++++++++++++++++++++++++++++++++++++++++++++ peer/peer.go | 33 ++++++-- 2 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 peer/connstats.go diff --git a/peer/connstats.go b/peer/connstats.go new file mode 100644 index 00000000..7aa3520b --- /dev/null +++ b/peer/connstats.go @@ -0,0 +1,199 @@ +package peer + +import ( + "context" + "encoding/json" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/getlantern/lantern-box/tracker/peerconn" +) + +// connStatsServer is the localhost HTTP endpoint Flutter polls to render +// the live globe. It maintains an in-memory set of active source IPs by +// subscribing to peerconn lifecycle notifications, and serves the current +// snapshot as JSON on GET /peer/connections. +// +// This is a deliberately simple bridge for the prototype: it skips the +// proper Go→FFI→Dart event channel (which Adam's lantern#8492 had a +// pattern for but is on a stale branch with merge conflicts) in favour of +// a poll loop. Replace with a streaming FFI events path once the broader +// peer-share / unbounded plumbing lands; the data shape is intentionally +// the same so Dart consumers don't need to change. +// +// Listen address: +// - RADIANCE_PEER_STATS_ADDR env var if set (e.g. "127.0.0.1:17099") +// - default 127.0.0.1:17099 +// +// 127.0.0.1 only — never bound to public interfaces. The endpoint reveals +// active proxy clients' IP addresses, which we don't want surfaced to +// anyone outside the local user's machine. +const defaultConnStatsAddr = "127.0.0.1:17099" + +type connEntry struct { + Source string `json:"source"` + Since time.Time `json:"since"` + Inbound int `json:"-"` // for refcount on duplicate accepts (re-uses) + id int // monotonic id for stable equality across snapshots +} + +type connSnapshot struct { + Sources []string `json:"sources"` + ActiveCount int `json:"active_count"` + GeneratedAt time.Time `json:"generated_at"` + ListenerHits int64 `json:"listener_hits"` +} + +type connStats struct { + mu sync.Mutex + active map[string]*connEntry + hits int64 + server *http.Server + listener net.Listener +} + +func newConnStats() *connStats { + return &connStats{active: make(map[string]*connEntry)} +} + +// note records a +1 or -1 transition. Source is "ip:port". +func (s *connStats) note(state int, source string) { + s.mu.Lock() + defer s.mu.Unlock() + s.hits++ + if state == +1 { + if e, ok := s.active[source]; ok { + e.Inbound++ + return + } + s.active[source] = &connEntry{ + Source: source, + Since: time.Now(), + Inbound: 1, + } + } else if state == -1 { + if e, ok := s.active[source]; ok { + e.Inbound-- + if e.Inbound <= 0 { + delete(s.active, source) + } + } + } +} + +func (s *connStats) snapshot() connSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + out := connSnapshot{ + Sources: make([]string, 0, len(s.active)), + ActiveCount: len(s.active), + GeneratedAt: time.Now(), + ListenerHits: s.hits, + } + for src := range s.active { + out.Sources = append(out.Sources, src) + } + return out +} + +// start spins up the HTTP server. Returns an error if the listen address +// is already in use; falls back to a kernel-assigned port (":0" suffix) +// only if the configured address conflicts and the env var was unset, so +// users who explicitly pinned a port get a clean failure. +func (s *connStats) start(parent context.Context) error { + addr := os.Getenv("RADIANCE_PEER_STATS_ADDR") + envSet := addr != "" + if !envSet { + addr = defaultConnStatsAddr + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + if envSet { + return err + } + // Default already taken — try a random localhost port so a second + // app instance still surfaces some endpoint rather than failing. + ln, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + } + s.listener = ln + + mux := http.NewServeMux() + mux.HandleFunc("/peer/connections", func(w http.ResponseWriter, r *http.Request) { + // Strict localhost gate. net.Listen on 127.0.0.1 already prevents + // remote connections, but a misconfigured listener (e.g. someone + // changing addr to ":17099" later) would happily accept LAN + // requests; this is a defense-in-depth check. + host, _, splitErr := net.SplitHostPort(r.RemoteAddr) + if splitErr != nil || !isLoopback(host) { + http.Error(w, "loopback only", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.snapshot()) + }) + + s.server = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + _ = s.server.Serve(ln) + }() + + // Tear down when the parent context is cancelled. + go func() { + <-parent.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.server.Shutdown(shutdownCtx) + }() + return nil +} + +func (s *connStats) addr() string { + if s.listener == nil { + return "" + } + return s.listener.Addr().String() +} + +func isLoopback(host string) bool { + host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// startConnStats wires the lantern-box peerconn listener through to a new +// connStats instance and starts its HTTP server. Returns the stats object +// (so peer.Client can read snapshots for its own internal stats) and an +// error if the HTTP listener can't be bound. +// +// On success the connection-event listener registered via peerconn is the +// stats notifier; callers SHOULD NOT register a competing listener while +// stats is running. Stop is by cancelling the supplied ctx. +func startConnStats(ctx context.Context) (*connStats, error) { + s := newConnStats() + if err := s.start(ctx); err != nil { + return nil, err + } + peerconn.SetListener(func(state int, source string) { + s.note(state, source) + }) + go func() { + <-ctx.Done() + peerconn.SetListener(nil) + }() + return s, nil +} + diff --git a/peer/peer.go b/peer/peer.go index c77051d3..b28500b5 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -296,14 +296,31 @@ func (c *Client) Start(ctx context.Context) error { } // Forward inbound accept/close events from lantern-box's samizdat - // inbound to the radiance event bus, so consumers (the Flutter globe, - // future abuse aggregation) get a per-connection stream. Listener is - // process-wide single-active; cleared on Stop. Register BEFORE Verify - // so the verify-dial connection itself emits an event — the listener - // must be in place by the time the server dials back. - peerconn.SetListener(func(state int, source string) { - events.Emit(ConnectionEvent{State: state, Source: source}) - }) + // inbound to the radiance event bus AND a localhost HTTP stats + // endpoint that Flutter polls to render the live globe. Listener is + // process-wide single-active; cleared automatically when runCtx + // cancels (in Stop / rollback). Must run AFTER box.Start so the + // box's accept loop is serving when notifications start flowing. + // Register BEFORE Verify so the verify-dial connection itself emits + // an event — the listener must be in place by the time the server + // dials back. + stats, statsErr := startConnStats(runCtx) + if statsErr != nil { + // Don't fail Start over a stats-endpoint error — a bound port + // shouldn't kill the user's peer-share session. Log and continue. + slog.Warn("peer connection stats endpoint failed to start", "err", statsErr) + } else { + // startConnStats sets a peerconn listener that feeds the snapshot + // HTTP server. Layer ConnectionEvent emission alongside, since + // Go-side consumers (e.g. metrics) may want the stream too. + peerconn.SetListener(func(state int, source string) { + stats.note(state, source) + events.Emit(ConnectionEvent{State: state, Source: source}) + }) + slog.Info("peer connection stats endpoint listening", + "addr", stats.addr(), + ) + } // Now that sing-box is listening with the just-built creds, ask the // server to dial back through them. Splitting verify out of Register From 52c8f30e2a9182012a5be9ad4e8883e1699b6b77 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 13:14:06 -0600 Subject: [PATCH 23/63] peer: drop localhost HTTP stats endpoint, keep ConnectionEvent emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP endpoint at 127.0.0.1:17099/peer/connections was added to bridge peer connection lifecycle to Flutter without writing FFI plumbing, but two problems with that approach: 1. Detectability — a fixed loopback port is a Lantern-specific fingerprint any local process (incl. malware) can probe. Sandboxed adversary on the user's machine could detect Lantern is running. 2. Local server adds attack surface for free. Reverting to ConnectionEvent emission only; Flutter consumption rides on the existing FlutterEventEmitter / Dart api_dl bridge in lantern-core (separate commit) which has no port footprint. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit a1c10cfe36fbd80e8b295e19a74c285388590f9c) --- peer/connstats.go | 199 ---------------------------------------------- peer/peer.go | 47 +++-------- 2 files changed, 10 insertions(+), 236 deletions(-) delete mode 100644 peer/connstats.go diff --git a/peer/connstats.go b/peer/connstats.go deleted file mode 100644 index 7aa3520b..00000000 --- a/peer/connstats.go +++ /dev/null @@ -1,199 +0,0 @@ -package peer - -import ( - "context" - "encoding/json" - "net" - "net/http" - "os" - "strings" - "sync" - "time" - - "github.com/getlantern/lantern-box/tracker/peerconn" -) - -// connStatsServer is the localhost HTTP endpoint Flutter polls to render -// the live globe. It maintains an in-memory set of active source IPs by -// subscribing to peerconn lifecycle notifications, and serves the current -// snapshot as JSON on GET /peer/connections. -// -// This is a deliberately simple bridge for the prototype: it skips the -// proper Go→FFI→Dart event channel (which Adam's lantern#8492 had a -// pattern for but is on a stale branch with merge conflicts) in favour of -// a poll loop. Replace with a streaming FFI events path once the broader -// peer-share / unbounded plumbing lands; the data shape is intentionally -// the same so Dart consumers don't need to change. -// -// Listen address: -// - RADIANCE_PEER_STATS_ADDR env var if set (e.g. "127.0.0.1:17099") -// - default 127.0.0.1:17099 -// -// 127.0.0.1 only — never bound to public interfaces. The endpoint reveals -// active proxy clients' IP addresses, which we don't want surfaced to -// anyone outside the local user's machine. -const defaultConnStatsAddr = "127.0.0.1:17099" - -type connEntry struct { - Source string `json:"source"` - Since time.Time `json:"since"` - Inbound int `json:"-"` // for refcount on duplicate accepts (re-uses) - id int // monotonic id for stable equality across snapshots -} - -type connSnapshot struct { - Sources []string `json:"sources"` - ActiveCount int `json:"active_count"` - GeneratedAt time.Time `json:"generated_at"` - ListenerHits int64 `json:"listener_hits"` -} - -type connStats struct { - mu sync.Mutex - active map[string]*connEntry - hits int64 - server *http.Server - listener net.Listener -} - -func newConnStats() *connStats { - return &connStats{active: make(map[string]*connEntry)} -} - -// note records a +1 or -1 transition. Source is "ip:port". -func (s *connStats) note(state int, source string) { - s.mu.Lock() - defer s.mu.Unlock() - s.hits++ - if state == +1 { - if e, ok := s.active[source]; ok { - e.Inbound++ - return - } - s.active[source] = &connEntry{ - Source: source, - Since: time.Now(), - Inbound: 1, - } - } else if state == -1 { - if e, ok := s.active[source]; ok { - e.Inbound-- - if e.Inbound <= 0 { - delete(s.active, source) - } - } - } -} - -func (s *connStats) snapshot() connSnapshot { - s.mu.Lock() - defer s.mu.Unlock() - out := connSnapshot{ - Sources: make([]string, 0, len(s.active)), - ActiveCount: len(s.active), - GeneratedAt: time.Now(), - ListenerHits: s.hits, - } - for src := range s.active { - out.Sources = append(out.Sources, src) - } - return out -} - -// start spins up the HTTP server. Returns an error if the listen address -// is already in use; falls back to a kernel-assigned port (":0" suffix) -// only if the configured address conflicts and the env var was unset, so -// users who explicitly pinned a port get a clean failure. -func (s *connStats) start(parent context.Context) error { - addr := os.Getenv("RADIANCE_PEER_STATS_ADDR") - envSet := addr != "" - if !envSet { - addr = defaultConnStatsAddr - } - - ln, err := net.Listen("tcp", addr) - if err != nil { - if envSet { - return err - } - // Default already taken — try a random localhost port so a second - // app instance still surfaces some endpoint rather than failing. - ln, err = net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return err - } - } - s.listener = ln - - mux := http.NewServeMux() - mux.HandleFunc("/peer/connections", func(w http.ResponseWriter, r *http.Request) { - // Strict localhost gate. net.Listen on 127.0.0.1 already prevents - // remote connections, but a misconfigured listener (e.g. someone - // changing addr to ":17099" later) would happily accept LAN - // requests; this is a defense-in-depth check. - host, _, splitErr := net.SplitHostPort(r.RemoteAddr) - if splitErr != nil || !isLoopback(host) { - http.Error(w, "loopback only", http.StatusForbidden) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(s.snapshot()) - }) - - s.server = &http.Server{ - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - } - go func() { - _ = s.server.Serve(ln) - }() - - // Tear down when the parent context is cancelled. - go func() { - <-parent.Done() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = s.server.Shutdown(shutdownCtx) - }() - return nil -} - -func (s *connStats) addr() string { - if s.listener == nil { - return "" - } - return s.listener.Addr().String() -} - -func isLoopback(host string) bool { - host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") - if host == "localhost" { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -// startConnStats wires the lantern-box peerconn listener through to a new -// connStats instance and starts its HTTP server. Returns the stats object -// (so peer.Client can read snapshots for its own internal stats) and an -// error if the HTTP listener can't be bound. -// -// On success the connection-event listener registered via peerconn is the -// stats notifier; callers SHOULD NOT register a competing listener while -// stats is running. Stop is by cancelling the supplied ctx. -func startConnStats(ctx context.Context) (*connStats, error) { - s := newConnStats() - if err := s.start(ctx); err != nil { - return nil, err - } - peerconn.SetListener(func(state int, source string) { - s.note(state, source) - }) - go func() { - <-ctx.Done() - peerconn.SetListener(nil) - }() - return s, nil -} - diff --git a/peer/peer.go b/peer/peer.go index b28500b5..f33b5ea6 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -13,7 +13,6 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" - box "github.com/getlantern/lantern-box" "github.com/getlantern/lantern-box/tracker/peerconn" "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/events" @@ -296,42 +295,16 @@ func (c *Client) Start(ctx context.Context) error { } // Forward inbound accept/close events from lantern-box's samizdat - // inbound to the radiance event bus AND a localhost HTTP stats - // endpoint that Flutter polls to render the live globe. Listener is - // process-wide single-active; cleared automatically when runCtx - // cancels (in Stop / rollback). Must run AFTER box.Start so the - // box's accept loop is serving when notifications start flowing. - // Register BEFORE Verify so the verify-dial connection itself emits - // an event — the listener must be in place by the time the server - // dials back. - stats, statsErr := startConnStats(runCtx) - if statsErr != nil { - // Don't fail Start over a stats-endpoint error — a bound port - // shouldn't kill the user's peer-share session. Log and continue. - slog.Warn("peer connection stats endpoint failed to start", "err", statsErr) - } else { - // startConnStats sets a peerconn listener that feeds the snapshot - // HTTP server. Layer ConnectionEvent emission alongside, since - // Go-side consumers (e.g. metrics) may want the stream too. - peerconn.SetListener(func(state int, source string) { - stats.note(state, source) - events.Emit(ConnectionEvent{State: state, Source: source}) - }) - slog.Info("peer connection stats endpoint listening", - "addr", stats.addr(), - ) - } - - // Now that sing-box is listening with the just-built creds, ask the - // server to dial back through them. Splitting verify out of Register - // into this explicit follow-up avoids the chicken-and-egg where the - // server tried to verify before the peer could possibly be listening - // (the cert/key only arrive in the Register response). Failure here - // is fatal — the server has already deprecated the row, so the - // deferred cleanup tears the rest of the session down. - if err := c.cfg.API.Verify(ctx, regResp.RouteID); err != nil { - return fmt.Errorf("verify with lantern-cloud: %w", err) - } + // inbound to the radiance event bus. Consumers (lantern-core's + // FlutterEventEmitter, future abuse aggregation) subscribe via + // events.Subscribe[ConnectionEvent]. Listener is process-wide + // single-active; cleared on Stop and in the rollback defer so + // post-teardown accept-loop callbacks land on a no-op rather than + // emit events to a torn-down consumer. Must run AFTER box.Start so + // the accept loop is serving when notifications start flowing. + peerconn.SetListener(func(state int, source string) { + events.Emit(ConnectionEvent{State: state, Source: source}) + }) // HeartbeatIntervalSeconds is server-driven so lantern-cloud can dial up // the cadence on registrations it wants to expire faster. Honor any From 3aa47f68d1f86d7719b19366fa9ae3ac35ec5fc5 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 6 May 2026 14:42:04 -0600 Subject: [PATCH 24/63] peer: register lantern-box protocols in box ctx + regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defaultBuildBoxService used to call libbox.NewServiceWithContext with the caller's bare ctx, which has no lantern-box protocol registries plumbed in. The samizdat inbound type ServerConfig sends back from /peer/register isn't a built-in sing-box protocol, so libbox's JSON decoder couldn't resolve inbounds[0].type="samizdat" and returned "missing inbound fields registry in context". The integration tests stub BuildBoxService entirely, so this layer was never exercised in CI — only surfaced live during the eero end-to-end test. Two pieces: 1. Use box.BaseContext() (from getlantern/lantern-box) when calling libbox.NewServiceWithContext. That ctx has the InboundOptionsRegistry populated with samizdat / reflex / etc. so the decode succeeds. Coexists with the user's VPN tunnel (vpn/tunnel.go) — libbox.Setup is process-global, the ctx registries are per-box. 2. TestDefaultBuildBoxService_DecodesSamizdatInbound walks the actual decode path with a minimal samizdat-inbound JSON. Verified to fail with the exact production error message under the pre-fix code, pass under the fix. Cuts the diagnostic loop from a 5-minute rebuild+redeploy+toggle cycle to a 0.5s test failure. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit b25b01b1cee85e21b8c6df583bb69c74880ecc8e) --- peer/peer.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/peer/peer.go b/peer/peer.go index f33b5ea6..de5f780d 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -13,6 +13,7 @@ import ( "github.com/sagernet/sing-box/experimental/libbox" + box "github.com/getlantern/lantern-box" "github.com/getlantern/lantern-box/tracker/peerconn" "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/events" @@ -529,7 +530,10 @@ func pickInternalPort() uint16 { // (samizdat, reflex, etc.) into the ctx so libbox can decode the // inbounds[0].type="samizdat" stanza coming back from /peer/register. // Without it the user's ctx is missing InboundOptionsRegistry and -// libbox returns "missing inbound fields registry in context". +// libbox returns "missing inbound fields registry in context" — the +// failure mode is silent in CI because the integration tests stub +// BuildBoxService entirely; only TestDefaultBuildBoxService_DecodesSamizdatInbound +// exercises the real decode path. // // We wrap so libbox sees the caller's Deadline/Done (so a Stop-induced // ctx cancel propagates to box internals) AND can still resolve the From d6d950e44a70b7b8d870e7570b596536f817b309 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 12:44:44 -0600 Subject: [PATCH 25/63] peer: silence connection-event cascade during box.Close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user toggles SmC off while real client traffic is flowing, box.Close fires per-connection disconnect callbacks for every in-flight inbound. peerconn.Notify reads its registered listener under an RLock and releases the lock before invoking — SetListener(nil) alone races against goroutines that have already snapshotted the listener (one per live connection). Each surviving callback hits events.Emit, which spawns yet another goroutine per subscriber. The Flutter-side subscriber posts main-thread tasks per event, and a hundred-task flood against an engine that's simultaneously handling the SmC-off state change reproduced as a Flutter mutex abort on the main thread. Add a sync/atomic flag the listener wrapper checks inline. Flip it before box.Close in both Stop and the Start-rollback defer; re-arm it at the top of Start so a Stop→Start cycle doesn't leave the wrapper muted. SetListener(nil) still runs for cleanliness, but the flag is what actually halts the cascade. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit f6774c61f1793296dfe5a4673157c3480c287bee) --- peer/peer.go | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index de5f780d..6307e973 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -9,6 +9,7 @@ import ( "math/rand/v2" "strconv" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/experimental/libbox" @@ -149,6 +150,19 @@ type Client struct { forwarder portForwarder box boxService routeID string + + // listenerDraining short-circuits the peerconn listener wrapper while + // box.Close is firing per-connection disconnect callbacks. peerconn.Notify + // reads its registered listener under an RLock and then releases the lock + // before invoking it, so SetListener(nil) alone races against in-flight + // Notify calls — under load (real client traffic), Close fires N disconnect + // callbacks from N goroutines that have already snapshotted the listener, + // each then events.Emit spawns one more goroutine per subscriber. The + // Flutter-side subscriber posts main-thread tasks per event, and a + // hundred-task flood against a Flutter engine that's simultaneously + // processing the SmC-off state change is the Flutter mutex crash we hit. + // Setting this flag before box.Close drops the cascade inline. + listenerDraining atomic.Bool } // peerCleanupTimeout caps how long Start's rollback path waits for @@ -204,6 +218,11 @@ func (c *Client) Start(ctx context.Context) error { c.startingDone = make(chan struct{}) c.mu.Unlock() + // Re-arm the listener wrapper. Stop / rollback flips this to true to + // silence the disconnect cascade during box.Close; if we don't reset + // here, a Stop→Start cycle would leave the wrapper permanently muted. + c.listenerDraining.Store(false) + var ( success bool fwd portForwarder @@ -230,7 +249,9 @@ func (c *Client) Start(ctx context.Context) error { // Always clear the connection listener on rollback. The listener is // only Set on the success path, so this is a no-op if Start failed // before reaching it — but cheap insurance against a future re-order - // that registers earlier. + // that registers earlier. Drain-flag first so any in-flight Notify + // callbacks short-circuit even if SetListener races (see Stop). + c.listenerDraining.Store(true) peerconn.SetListener(nil) if box != nil { _ = box.Close() @@ -304,6 +325,9 @@ func (c *Client) Start(ctx context.Context) error { // emit events to a torn-down consumer. Must run AFTER box.Start so // the accept loop is serving when notifications start flowing. peerconn.SetListener(func(state int, source string) { + if c.listenerDraining.Load() { + return + } events.Emit(ConnectionEvent{State: state, Source: source}) }) @@ -394,9 +418,15 @@ func (c *Client) Stop(ctx context.Context) error { c.status = Status{} c.mu.Unlock() - // Clear the connection listener BEFORE box.Close so any in-flight - // accept-loop callbacks land on a no-op rather than emit ConnectionEvents - // after the consumer side has already torn down its subscription. + // Suppress the connection listener BEFORE box.Close. peerconn.Notify + // reads its registered listener under an RLock and releases it before + // invoking — SetListener(nil) alone races against in-flight Notify + // goroutines that have already snapshotted the listener (one per live + // inbound connection at Close time). Flipping listenerDraining first + // short-circuits the wrapper inline so even the racing invocations + // become no-ops. SetListener(nil) is still called for cleanliness and + // to release the listener closure's reference to this Client. + c.listenerDraining.Store(true) peerconn.SetListener(nil) cancel() From 1fd22b4c6810c0745fddb768ff1fbe866b370685 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 13:32:06 -0600 Subject: [PATCH 26/63] peer: emit phase-granular StatusEvents through Start/Stop lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI today sees a single active/inactive flip — toggling SmC on looks "hung" through the multi-second sequence of port-forwarding, registering, starting the local box, and verifying. This adds a Phase field to Status and emits one StatusEvent per stage: Start: mapping_port → detecting_ip → registering → starting_proxy → verifying → serving Stop: stopping → idle on err: error (Status.Error populated with the wrapped fmt.Errorf message, e.g. "map port 33445: upnp gateway refused mapping") Phase is a stable string so Flutter / web consumers can switch on it without depending on Go enum ordering. Active stays as a derived bool (true only on PhaseServing) for subscribers that just want the binary. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 39b6b454965b256c24dc4204b9240f895d124187) --- peer/peer.go | 84 +++++++++++++++++++++++++++++++++++++++++-- peer/peer_test.go | 90 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 157 insertions(+), 17 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index 6307e973..869a12f0 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -103,7 +103,34 @@ type boxService interface { type boxFactory func(ctx context.Context, options string) (boxService, error) +// Phase is the peer.Client lifecycle stage surfaced to the UI. Granular +// enough that "Share My Connection" can render a real progress sequence +// (mapping port → registering → verifying → serving) instead of a single +// active/inactive boolean. Values are stable strings so Flutter / web +// consumers can switch on them without depending on Go enum ordering. +type Phase string + +const ( + PhaseIdle Phase = "idle" + PhaseMappingPort Phase = "mapping_port" + PhaseDetectingIP Phase = "detecting_ip" + PhaseRegistering Phase = "registering" + PhaseStartingBox Phase = "starting_proxy" + PhaseVerifying Phase = "verifying" + PhaseServing Phase = "serving" + PhaseStopping Phase = "stopping" + PhaseError Phase = "error" +) + type Status struct { + Phase Phase `json:"phase"` + // Error is the human-readable failure reason when Phase == PhaseError. + // Empty for every other phase; consumers should render this only when + // the UI is in the error state. + Error string `json:"error,omitempty"` + // Active is true only when Phase == PhaseServing. Kept distinct from + // Phase so subscribers that just want a boolean "is sharing?" don't + // have to switch on the phase enum. Active bool `json:"active"` SharingSince time.Time `json:"sharing_since,omitempty"` ExternalIP string `json:"external_ip,omitempty"` @@ -208,7 +235,7 @@ func NewClient(cfg Config) (*Client, error) { // Start opens the peer-proxy session. On success a background heartbeat // goroutine is running; on error any partial setup is torn down before // returning. -func (c *Client) Start(ctx context.Context) error { +func (c *Client) Start(ctx context.Context) (retErr error) { c.mu.Lock() if c.active || c.starting { c.mu.Unlock() @@ -265,8 +292,20 @@ func (c *Client) Start(ctx context.Context) error { if fwd != nil { _ = fwd.UnmapPort(cleanupCtx) } + // Surface the failure to the UI. Emitted AFTER cleanup so the UI + // sees the error phase as the terminal state of this Start attempt, + // not as a transient between phases. retErr carries whichever + // fmt.Errorf the failing branch returned, which is the most + // human-readable diagnostic we have ("map port %d: ...", + // "register with lantern-cloud: ...", etc.). + var errMsg string + if retErr != nil { + errMsg = retErr.Error() + } + c.emitPhase(PhaseError, errMsg) }() + c.emitPhase(PhaseMappingPort, "") fwd, err := c.cfg.NewForwarder(ctx) if err != nil { return fmt.Errorf("discover gateway: %w", err) @@ -277,10 +316,13 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("map port %d: %w", internalPort, err) } + c.emitPhase(PhaseDetectingIP, "") externalIP, err := fwd.ExternalIP(ctx) if err != nil { return fmt.Errorf("get external ip: %w", err) } + + c.emitPhase(PhaseRegistering, "") regResp, err = c.cfg.API.Register(ctx, RegisterRequest{ ExternalIP: externalIP, ExternalPort: mapping.ExternalPort, @@ -297,6 +339,7 @@ func (c *Client) Start(ctx context.Context) error { // auto_detect_interface tells sing-box to bind outbound dials to the // underlying physical interface rather than whatever the OS routing // table picks (which would be the VPN TUN if the VPN is up). + c.emitPhase(PhaseStartingBox, "") options, err := ensurePeerOutboundsBypassVPN(regResp.ServerConfig) if err != nil { return fmt.Errorf("patch sing-box options: %w", err) @@ -316,6 +359,18 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("start sing-box: %w", err) } + c.emitPhase(PhaseVerifying, "") + // Now that sing-box is listening with the just-built creds, ask the + // server to dial back through them. Splitting verify out of Register + // into this explicit follow-up avoids the chicken-and-egg where the + // server tried to verify before the peer could possibly be listening + // (the cert/key only arrive in the Register response). Failure here + // is fatal — the server has already deprecated the row, so the + // deferred cleanup tears the rest of the session down. + if err := c.cfg.API.Verify(ctx, regResp.RouteID); err != nil { + return fmt.Errorf("verify with lantern-cloud: %w", err) + } + // Forward inbound accept/close events from lantern-box's samizdat // inbound to the radiance event bus. Consumers (lantern-core's // FlutterEventEmitter, future abuse aggregation) subscribe via @@ -354,6 +409,7 @@ func (c *Client) Start(ctx context.Context) error { c.cancelRun = cancelRun c.runDone = runDone c.status = Status{ + Phase: PhaseServing, Active: true, SharingSince: time.Now(), ExternalIP: externalIP, @@ -415,8 +471,10 @@ func (c *Client) Stop(ctx context.Context) error { c.forwarder = nil c.box = nil c.routeID = "" - c.status = Status{} + c.status = Status{Phase: PhaseStopping} + stoppingSnapshot := c.status c.mu.Unlock() + events.Emit(StatusEvent{Status: stoppingSnapshot}) // Suppress the connection listener BEFORE box.Close. peerconn.Notify // reads its registered listener under an RLock and releases it before @@ -450,7 +508,11 @@ func (c *Client) Stop(ctx context.Context) error { slog.Warn("peer client unmap port failed", "err", err) } slog.Info("peer client stopped", "route_id", routeID) - events.Emit(StatusEvent{Status: Status{}}) + c.mu.Lock() + c.status = Status{Phase: PhaseIdle} + idleSnapshot := c.status + c.mu.Unlock() + events.Emit(StatusEvent{Status: idleSnapshot}) return firstErr } @@ -466,6 +528,22 @@ func (c *Client) CurrentStatus() Status { return c.status } +// emitPhase updates c.status.Phase under the lock and emits a snapshot. +// Used at each lifecycle boundary in Start / Stop so the UI sees progress +// instead of a binary active/inactive flip. Active is recomputed here: +// only PhaseServing implies active=true; every other phase clears it so +// subscribers using just the Active flag don't see e.g. "active=true with +// Phase=verifying" mid-Start. +func (c *Client) emitPhase(p Phase, errMsg string) { + c.mu.Lock() + c.status.Phase = p + c.status.Error = errMsg + c.status.Active = (p == PhaseServing) + snapshot := c.status + c.mu.Unlock() + events.Emit(StatusEvent{Status: snapshot}) +} + // heartbeatLoop closes done on exit so Stop can wait for the loop before // tearing down resources. The channel is passed in rather than read off the // Client because Stop nils c.runDone before waiting on its local copy. diff --git a/peer/peer_test.go b/peer/peer_test.go index e465d2ea..29e9c0fe 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -685,35 +685,97 @@ func TestAPIError_StringFormat(t *testing.T) { assert.Contains(t, e.Error(), "could not connect") } -// Subscribers (the IPC SSE handler in production) need both edges so the UI -// can render fresh state without polling. +// TestClient_StatusEventEmittedOnStartAndStop pins the full lifecycle +// phase sequence: Start fires one StatusEvent per stage so the UI can +// render granular progress (mapping port → registering → verifying → +// serving) instead of a single active/inactive flip. Stop fires +// stopping → idle on the way back down. +// +// Subscribers (the IPC SSE handler in production) need every edge so the +// UI can render fresh state without polling. func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) { fwd := &fakeForwarder{} box := &fakeBoxService{} srv := newStubServer(t) c := newTestClient(t, fwd, box, srv) - got := make(chan StatusEvent, 4) + // Buffer must exceed total emit count (6 on Start: mapping → detecting + // → registering → starting_proxy → verifying → serving; 2 on Stop: + // stopping → idle) or the subscriber's send blocks and emits drop. + got := make(chan StatusEvent, 16) sub := events.Subscribe(func(evt StatusEvent) { got <- evt }) defer sub.Unsubscribe() require.NoError(t, c.Start(context.Background())) - select { - case evt := <-got: - assert.True(t, evt.Status.Active) - assert.NotEmpty(t, evt.Status.RouteID) - case <-time.After(time.Second): - t.Fatal("no Start status event within 1s") + + wantStartPhases := []Phase{ + PhaseMappingPort, + PhaseDetectingIP, + PhaseRegistering, + PhaseStartingBox, + PhaseVerifying, + PhaseServing, + } + for _, want := range wantStartPhases { + select { + case evt := <-got: + assert.Equal(t, want, evt.Status.Phase, "wrong phase in Start sequence") + if want == PhaseServing { + assert.True(t, evt.Status.Active, "active must be true on serving") + assert.NotEmpty(t, evt.Status.RouteID, "route_id must be set on serving") + } else { + assert.False(t, evt.Status.Active, "active must be false on intermediate phase %q", want) + } + case <-time.After(time.Second): + t.Fatalf("no Start status event for phase %q within 1s", want) + } } require.NoError(t, c.Stop(context.Background())) - select { - case evt := <-got: - assert.False(t, evt.Status.Active) - case <-time.After(time.Second): - t.Fatal("no Stop status event within 1s") + for _, want := range []Phase{PhaseStopping, PhaseIdle} { + select { + case evt := <-got: + assert.Equal(t, want, evt.Status.Phase, "wrong phase in Stop sequence") + assert.False(t, evt.Status.Active, "active must be false during stop") + case <-time.After(time.Second): + t.Fatalf("no Stop status event for phase %q within 1s", want) + } + } +} + +// TestClient_StatusEventOnStartError surfaces a Start failure to the UI +// via PhaseError with the wrapped error message. Without this, a user +// who clicks SmC-on and hits e.g. a UPnP failure sees the toggle silently +// flip back without any diagnostic. +func TestClient_StatusEventOnStartError(t *testing.T) { + fwd := &fakeForwarder{mapErr: errors.New("upnp gateway refused mapping")} + box := &fakeBoxService{} + srv := newStubServer(t) + c := newTestClient(t, fwd, box, srv) + + got := make(chan StatusEvent, 16) + sub := events.Subscribe(func(evt StatusEvent) { got <- evt }) + defer sub.Unsubscribe() + + err := c.Start(context.Background()) + require.Error(t, err) + + var sawError bool + deadline := time.After(time.Second) + for !sawError { + select { + case evt := <-got: + if evt.Status.Phase == PhaseError { + sawError = true + assert.False(t, evt.Status.Active) + assert.Contains(t, evt.Status.Error, "upnp gateway refused mapping", + "error message must surface so the UI can render a real diagnostic") + } + case <-deadline: + t.Fatal("no PhaseError status event within 1s") + } } } From 88c3345fff5efb12c58274d5e93e0e6f027b4594 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 14:30:02 -0600 Subject: [PATCH 27/63] peer: instrument peerconn listener registration + per-event forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "no globe arcs despite 200+ samizdat connections" pattern is unobservable from current logs: peerconn.SetListener and events.Emit don't log, so when the chain breaks between samizdat-in's Notify and the Flutter bridge, there's no trace. This adds three breadcrumbs to make the failure mode diagnosable on the next rebuild: - "peer listener: registered with peerconn" — one line per Start that confirms the listener actually got installed - "peer listener: forwarding connection event" — one line per accept AND per close; pairs with the lantern-core subscriber breadcrumb so we can see if events bus delivers what the listener emits - "peer listener: dropping post-Stop Notify" — DEBUG-level for the race window the listenerDraining flag silences; makes that bucket countable instead of silently discarding events Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit bf26ce2e04119ef9c86f2a90eb7c40527cf544f4) --- peer/peer.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/peer/peer.go b/peer/peer.go index 869a12f0..fb8cf8c2 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -381,10 +381,23 @@ func (c *Client) Start(ctx context.Context) (retErr error) { // the accept loop is serving when notifications start flowing. peerconn.SetListener(func(state int, source string) { if c.listenerDraining.Load() { + // Diagnostic: if Notify reaches this point but we drop because + // the drain flag is set, that's the post-Stop racing-Notify case + // the flag was added to silence. Logging makes its frequency + // observable instead of "events silently vanish." + slog.Debug("peer listener: dropping post-Stop Notify", + "state", state, "source", source) return } + // One-line breadcrumb per accept/close so we can correlate samizdat-in + // activity with peer-connection FlutterEvents on the consumer side + // — without this, "no globe arcs despite samizdat traffic" is + // indistinguishable from "events fire but the bridge swallows them." + slog.Info("peer listener: forwarding connection event", + "state", state, "source", source) events.Emit(ConnectionEvent{State: state, Source: source}) }) + slog.Info("peer listener: registered with peerconn", "route_id", regResp.RouteID) // HeartbeatIntervalSeconds is server-driven so lantern-cloud can dial up // the cadence on registrations it wants to expire faster. Honor any From 23ccb09b71315566006d1ada9411c65069059a49 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 15:21:27 -0600 Subject: [PATCH 28/63] events: log Emit subscriber count to debug "events vanish" path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The radiance peer listener fires (42 ConnectionEvents observed) but lantern-core's subscriber breadcrumb never fires, suggesting either Subscribe never ran or Emit is looking at a different subscriptions map. Logs the type key + subscriber count at every Emit so we can distinguish "no subscribers registered" (init bug) from "subscribers registered but callback panics" (rare, but possible). Uses stdlib log to avoid pulling slog into the events package (and a possible import cycle with slog-forwarding handlers that subscribe to events). Temporary diagnostic — should be downgraded to Debug or removed once the chain works end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 810ef9b82e62ae36bc3cef2b8dbb71654fd0d53c) --- events/events.go | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/events/events.go b/events/events.go index fba0d7a6..a5e37cd4 100644 --- a/events/events.go +++ b/events/events.go @@ -28,6 +28,7 @@ package events import ( "context" + stdlog "log" "reflect" "sync" "sync/atomic" @@ -120,9 +121,27 @@ func (e *Subscription[T]) Unsubscribe() { func Emit[T Event](evt T) { subscriptionsMu.RLock() defer subscriptionsMu.RUnlock() - if subs, ok := subscriptions[reflect.TypeFor[T]()]; ok { - for _, cb := range subs { - go cb(evt) - } + key := reflect.TypeFor[T]() + subs, ok := subscriptions[key] + // Diagnostic: surfaces the subscriber count at emit time so a missing + // FlutterEvent on the consumer side is distinguishable from "no + // subscribers registered for this type" vs "subscribers registered + // but callback panics silently." Spam-friendly when traffic spikes, + // but we're investigating a zero-callback path so the noise is + // short-lived; remove (or downgrade to Debug) once the chain works. + emitDebugLogger(key, len(subs)) + if !ok { + return + } + for _, cb := range subs { + go cb(evt) } } + +// emitDebugLogger is a package-level var so tests can suppress the +// per-emit log, and so prod can swap in slog. Default uses Go's stdlib +// log so events package doesn't need to import slog (and avoid a cycle +// with anything that imports events for its own log forwarding). +var emitDebugLogger = func(key reflect.Type, subCount int) { + stdlog.Printf("events.Emit type=%s subscribers=%d", key, subCount) +} From e93d8a43815e5e5bd6568a820f4b2b7677888482 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 15:47:52 -0600 Subject: [PATCH 29/63] ipc: stream peer-status + peer-connection events over IPC SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The events package's globals are process-scoped — events.Emit in lanternd (where radiance/peer runs) doesn't reach events.Subscribe in Liblantern. Diagnostic at events.go showed subscribers=0 for every peer.ConnectionEvent emit despite Subscribe being called. Adds the cross-process bridge: - New /peer/connection/events SSE endpoint (mirrors /peer/status/events). peerConnectionEventsHandler buffers 64 events to absorb slow consumers without backpressuring events.Emit; drops on overflow rather than growing unbounded. - Client.PeerStatusEvents(ctx, handler) and Client.PeerConnectionEvents( ctx, handler) in both mobile and nonmobile client variants. Mobile keeps the events.SubscribeContext path so in-process delivery still works for builds that bundle radiance with the consumer; otherwise falls through to SSE. The peer-status SSE endpoint and handler were already there; this PR just adds the matching client method so lantern-core can actually consume it. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 29a4b7e3bec82e3ce701734bd9ca80e6db015474) --- ipc/client_events_mobile.go | 35 ++++++++++++++++++++++ ipc/client_events_nonmobile.go | 34 +++++++++++++++++++++ ipc/server.go | 55 ++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/ipc/client_events_mobile.go b/ipc/client_events_mobile.go index d03fca66..a3a8dfd2 100644 --- a/ipc/client_events_mobile.go +++ b/ipc/client_events_mobile.go @@ -9,6 +9,7 @@ import ( "github.com/getlantern/radiance/account" "github.com/getlantern/radiance/config" "github.com/getlantern/radiance/events" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/vpn" ) @@ -60,3 +61,37 @@ func (c *Client) DataCapStream(ctx context.Context, handler func(account.DataCap } return c.dataCapStream(ctx, handler) } + +// PeerStatusEvents — see client_events_nonmobile.go for the full +// docstring. Mobile builds may share a process with radiance (localOnly) +// in which case events.SubscribeContext delivers directly; otherwise the +// SSE retry loop matches the desktop path. +func (c *Client) PeerStatusEvents(ctx context.Context, handler func(peer.StatusEvent)) error { + events.SubscribeContext(ctx, handler) + if c.localOnly { + <-ctx.Done() + return ctx.Err() + } + return c.sseRetryLoop(ctx, peerStatusEventsEndpoint, func(data []byte) { + var evt peer.StatusEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} + +// PeerConnectionEvents — see client_events_nonmobile.go for the full +// docstring. Same mobile dual-path as PeerStatusEvents. +func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.ConnectionEvent)) error { + events.SubscribeContext(ctx, handler) + if c.localOnly { + <-ctx.Done() + return ctx.Err() + } + return c.sseRetryLoop(ctx, peerConnectionEventsEndpoint, func(data []byte) { + var evt peer.ConnectionEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} diff --git a/ipc/client_events_nonmobile.go b/ipc/client_events_nonmobile.go index 16d3184e..e0330fe1 100644 --- a/ipc/client_events_nonmobile.go +++ b/ipc/client_events_nonmobile.go @@ -7,6 +7,7 @@ import ( "encoding/json" "github.com/getlantern/radiance/account" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/vpn" ) @@ -40,3 +41,36 @@ func (c *Client) VPNStatusEvents(ctx context.Context, handler func(vpn.StatusUpd func (c *Client) DataCapStream(ctx context.Context, handler func(account.DataCapInfo)) error { return c.dataCapStream(ctx, handler) } + +// PeerStatusEvents streams peer-share lifecycle phase changes (mapping_port +// → registering → verifying → serving on Start, stopping → idle on Stop, +// error on failure). Each frame is a peer.StatusEvent JSON whose .Status +// is the live snapshot at the moment the event fired — consumers SHOULD +// re-render on every frame rather than diffing, since events.Emit's +// per-callback goroutine can land Start phases out of order. Blocks until +// ctx is cancelled. +func (c *Client) PeerStatusEvents(ctx context.Context, handler func(peer.StatusEvent)) error { + return c.sseRetryLoop(ctx, peerStatusEventsEndpoint, func(data []byte) { + var evt peer.StatusEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} + +// PeerConnectionEvents streams accept/close events for the local +// samizdat-in inbound. State is +1 on accept and -1 on close; Source +// is the remote "ip:port" string for geo-lookup / abuse attribution. +// Blocks until ctx is cancelled. +// +// Why this exists alongside events.Subscribe[peer.ConnectionEvent]: +// the events package's globals are process-scoped, so a subscriber in +// Liblantern can't see emits in lanternd. The SSE path bridges them. +func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.ConnectionEvent)) error { + return c.sseRetryLoop(ctx, peerConnectionEventsEndpoint, func(data []byte) { + var evt peer.ConnectionEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} diff --git a/ipc/server.go b/ipc/server.go index 114443b7..6825e7b8 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -65,8 +65,9 @@ const ( settingsEndpoint = "/settings" // Peer-share ("Share My Connection") endpoints - peerStatusEndpoint = "/peer/status" - peerStatusEventsEndpoint = "/peer/status/events" + peerStatusEndpoint = "/peer/status" + peerStatusEventsEndpoint = "/peer/status/events" + peerConnectionEventsEndpoint = "/peer/connection/events" // Split tunnel endpoint splitTunnelEndpoint = "/split-tunnel" @@ -234,6 +235,7 @@ func newLocalAPI(b *backend.LocalBackend, withAuth bool) *localapi { mux.HandleFunc("GET "+peerStatusEndpoint, traced(s.peerStatusHandler)) // SSE skips the tracer middleware since it buffers the entire response body. mux.HandleFunc("GET "+peerStatusEventsEndpoint, s.peerStatusEventsHandler) + mux.HandleFunc("GET "+peerConnectionEventsEndpoint, s.peerConnectionEventsHandler) // Split tunnel mux.HandleFunc(splitTunnelEndpoint, traced(s.splitTunnelHandler)) @@ -516,6 +518,55 @@ func (s *localapi) peerStatusEventsHandler(w http.ResponseWriter, r *http.Reques } } +// peerConnectionEventsHandler streams peer.ConnectionEvent over SSE for +// each accept/close on the local samizdat-in. Unlike peerStatusEventsHandler +// (which always sends the live snapshot), each emit's captured value is +// what the consumer needs here — the Source IP and +1/-1 state ARE the +// payload, not a periodic poll. Out-of-order +1/-1 from events.Emit's +// per-callback goroutine is fine: the consumer (lantern-core's globe-arc +// renderer) keys arcs by source, so it handles re-orderings naturally. +// +// The events package lives in this process (lanternd); cross-process +// consumers in Liblantern can only receive these via this SSE stream, +// since events.Subscribe in the Liblantern process sees a different +// (empty) subscriptions map. +func (s *localapi) peerConnectionEventsHandler(w http.ResponseWriter, r *http.Request) { + flusher := sseWriter(w) + if flusher == nil { + return + } + // Buffered channel so a slow SSE consumer doesn't apply backpressure + // to events.Emit (which spawns a goroutine per subscriber but blocks + // nothing). 64 holds ~one second of accept/close pairs under heavy + // load; beyond that we drop to avoid unbounded memory growth. + queue := make(chan peer.ConnectionEvent, 64) + sub := events.Subscribe(func(evt peer.ConnectionEvent) { + select { + case queue <- evt: + default: + // queue full — drop. SSE consumer is too slow; better to + // lose this event than to back up the events.Emit goroutine. + } + }) + defer sub.Unsubscribe() + + for { + select { + case evt := <-queue: + data, err := json.Marshal(evt) + if err != nil { + continue + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return + } + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + /////////////////////// // Server selection // /////////////////////// From 3debe3924c098027ac2e809e1c33eb0ffcbfaa6d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 16:12:47 -0600 Subject: [PATCH 30/63] bump lantern-box: real peer addr in ConnectionEvent.Source lantern-box bumps samizdat to plumb the underlying TLS conn's RemoteAddr through serverStreamConn. With this, peer.ConnectionEvent emitted from the peerconn listener carries a real peer ip:port instead of the "client:0" placeholder, so the Dart Share My Connection UI can key globe arcs per actual peer (and arcs persist through real connection lifetimes instead of flickering). Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 0b72cd457c21d8a9fcba2f8f4b4dc1c1ad4552f7) --- go.mod | 6 +----- go.sum | 18 ++---------------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 4f8586bd..07d0cc5d 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,6 @@ module github.com/getlantern/radiance go 1.26.2 -// Local while peerconn listener registry is in flight; remove once -// lantern-box tags a release that includes tracker/peerconn. -replace github.com/getlantern/lantern-box => ../lantern-box - replace github.com/sagernet/sing => github.com/getlantern/sing v0.7.18-lantern replace github.com/sagernet/sing-box => github.com/getlantern/sing-box-minimal v1.12.22-lantern @@ -36,7 +32,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab - github.com/getlantern/lantern-box v0.0.86 + github.com/getlantern/lantern-box v0.0.87-0.20260529195337-0b63c0f42962 github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb diff --git a/go.sum b/go.sum index be09ac43..5d6c042c 100644 --- a/go.sum +++ b/go.sum @@ -246,26 +246,12 @@ github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2y github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A= github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/47Hfk7FjW6yaD+1h6kO7C/iauV0DkVia/bXU= github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= -<<<<<<< HEAD github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab h1:PitYhTvo3oHRKYl4pVAoOIN8bhM+Bw+JBWncMglvHSg= github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.86 h1:myJa+Crg/oMgqSFhX7DOox4XcVIx8VFiPnkel8x8YT4= -github.com/getlantern/lantern-box v0.0.86/go.mod h1:BVXPyEicSu7m4nQY1OHPkOZNj87M7sYrzmY9AgyiPkc= -======= -<<<<<<< HEAD -github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03 h1:dUTN7mnTTBcSvsURNs1rTlyKrD1uXUEPqxEZDfl+hb4= -github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.84 h1:y+nezmu0LZDlzcS2A4oKDu3f1UTFAgA24vT1htvEiX0= -github.com/getlantern/lantern-box v0.0.84/go.mod h1:6SO1p22tAq9y8JLjNnAbr4/GZ4VjmlcQGYn0qF4aD/k= ->>>>>>> cd64ed8 (peer: emit ConnectionEvent on samizdat accept/close) +github.com/getlantern/lantern-box v0.0.87-0.20260529195337-0b63c0f42962 h1:VSSC7BIn42+tQmhoYg7Wc+ilkXC4SdoJ0LQ6+4kvtC0= +github.com/getlantern/lantern-box v0.0.87-0.20260529195337-0b63c0f42962/go.mod h1:BVXPyEicSu7m4nQY1OHPkOZNj87M7sYrzmY9AgyiPkc= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395 h1:grfGavAUp2E9w9ZoJuM3FyWyQ0sCJ64V4ZMKtZKRqTc= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= -======= -github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40 h1:P5pkaBGxWOGBn7bKzjzdln/ro+ShG1RUbOuy+7pSzXE= -github.com/getlantern/kindling v0.0.0-20260428171407-6143132aaf40/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= -github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= ->>>>>>> 231462b (peer: emit ConnectionEvent on samizdat accept/close) github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y= github.com/getlantern/osversion v0.0.0-20240418205916-2e84a4a4e175 h1:JWH5BB2o0eAeGs0tZnFPpQGx+nMIo/WmxKnj2hnGjgE= From ec71803db3f012c6b50c5d77f0f7fc14bfdef65b Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 18:51:13 -0600 Subject: [PATCH 31/63] peer: address Copilot review on #472 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight findings on the cred-rotation lifecycle: 1. credRotationLoop time.NewTicker panic on non-positive interval. Now clamps interval <= 0 to peerCredRotationInterval (the same default Start applies when CredRotationInterval is unset) instead of letting NewTicker panic the host process. 2. rotateCreds raced Stop. Re-check c.active under the swap lock immediately before assigning c.box / c.routeID; if Stop has cleared that state in flight, close the newly-built box and deregister the new route rather than resurrecting peer state Stop just tore down. 3. heartbeatLoop 404 raced cred rotation. heartbeatLoop captured c.routeID, sent the heartbeat, and treated a 404 as authoritative — but a rotation between those two steps deregisters the captured route_id, producing a 404 that's actually a stale response and should not trigger auto-Stop. heartbeatLoop now re-checks the current routeID under lock on 404; if it differs, the 404 is expected and the loop continues. 4. Status.ExternalIP went stale after rotation. rotation re-queries ExternalIP for Register, so update c.status.ExternalIP alongside c.status.RouteID under the swap lock. 5. Newly-registered route leaked when BuildBoxService or the subsequent Start failed. Threaded a cleanupNewRoute(reason) closure through every post-Register error path so the orphan row is deregistered with a fresh ctx (avoids cancellation-skip) rather than leaking until TTL. 6. Long availability outage when newBox.Start failed after oldBox.Close released the port. New startNewBoxWithRetry retries Start up to 5 times with exponential backoff (50ms → 800ms, total <1s) to absorb router-side TIME_WAIT / EADDRINUSE windows; preserves the previous 'fall through to next tick' behavior only after the retries are exhausted. 7. libbox.Start panic could crash the host process during background rotation, taking the user's main VPN with it (vpn/tunnel.go's tunnel-start path has a parallel recover). New runRotation wrapper defers a recover + slog.Error and lets the loop continue. 8. TestClient_RotatesCredentialsAtInterval asserted deregisterCount >= rotations-1, which would pass even if one rotation never deregistered. Tightened to >= rotations via require.Eventually so the test waits for the most recent deregister to land (rotation issues it after the swap-lock completes, so there's a small race window between observing the new register and the corresponding deregister). Tests pass under -race -count=1 across 5 consecutive runs. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 125 +++++++++++++++++++++++++++++++++++++++++----- peer/peer_test.go | 10 +++- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index a41f5eec..dfa77cf8 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "math/rand/v2" + "runtime/debug" "sync" "time" @@ -430,6 +431,20 @@ func (c *Client) heartbeatLoop(ctx context.Context, interval time.Duration, done // later heartbeat as a 404. slog.Warn("peer heartbeat failed", "err", err, "route_id", routeID) if isNotRegistered(err) { + // Re-check current routeID under lock. If credRotationLoop + // swapped routeID + deregistered the prior route between + // our heartbeat-prepare and heartbeat-response, the 404 + // applies to a stale route and is expected, not a reason + // to stop. Skip the auto-Stop and let the next tick + // heartbeat the new route. + c.mu.Lock() + currentRouteID := c.routeID + c.mu.Unlock() + if currentRouteID != routeID { + slog.Info("peer heartbeat 404 on stale route_id; rotation in flight, continuing", + "stale_route_id", routeID, "current_route_id", currentRouteID) + continue + } slog.Info("peer route no longer registered server-side, stopping client") // Stop runs in a separate goroutine to avoid the cyclic // Stop → cancelRun → loop-exit deadlock. @@ -465,6 +480,14 @@ func isNotRegistered(err error) bool { // failures are non-fatal (log + retry next tick), so there's nothing // the Stop path needs to wait on from this goroutine. func (c *Client) credRotationLoop(ctx context.Context, interval time.Duration) { + // Non-positive interval would panic time.NewTicker. Treat it the same + // as the zero case Start handles when CredRotationInterval is unset: + // fall back to the default cap rather than disabling rotation + // silently (an unset value still wants rotation; a negative value is + // almost certainly a test/config bug). + if interval <= 0 { + interval = peerCredRotationInterval + } t := time.NewTicker(interval) defer t.Stop() for { @@ -472,15 +495,31 @@ func (c *Client) credRotationLoop(ctx context.Context, interval time.Duration) { case <-ctx.Done(): return case <-t.C: - if err := c.rotateCreds(ctx); err != nil { - // Don't kill the loop on a single failure — current - // box / route is still serving. Try again next tick. - slog.Warn("peer cred rotation failed; current creds remain in use", "err", err) - } + c.runRotation(ctx) } } } +// runRotation wraps rotateCreds with a panic recover. libbox.Start can +// panic (see the recover in vpn/tunnel.go's tunnel-start path); an +// unrecovered panic here would crash the host process during a +// background rotation, taking the user's main VPN with it. Treat a +// panic the same as any other rotation failure: log, keep the existing +// box serving, try again next tick. +func (c *Client) runRotation(ctx context.Context) { + defer func() { + if r := recover(); r != nil { + slog.Error("peer cred rotation panicked; current creds remain in use", + "panic", r, "stack", string(debug.Stack())) + } + }() + if err := c.rotateCreds(ctx); err != nil { + // Don't kill the loop on a single failure — current + // box / route is still serving. Try again next tick. + slog.Warn("peer cred rotation failed; current creds remain in use", "err", err) + } +} + // rotateCreds atomically swaps the peer's samizdat credentials. On // success: a fresh route_id and keypair are in use, the libbox inbound // has been rebuilt against the new options, the prior route is @@ -532,8 +571,24 @@ func (c *Client) rotateCreds(ctx context.Context) error { if err != nil { return fmt.Errorf("re-register: %w", err) } + // From here on, any error path must deregister regResp.RouteID — + // otherwise the newly-created server-side row leaks until TTL expiry + // and the bandit catalog may briefly hand out creds for a route + // whose box never came up. + cleanupNewRoute := func(reason error) { + // Use a fresh ctx so a cancelled rotation ctx doesn't skip the + // cleanup we just made necessary. + cleanupCtx, cancel := context.WithTimeout(context.Background(), peerCleanupTimeout) + defer cancel() + if dErr := c.cfg.API.Deregister(cleanupCtx, regResp.RouteID); dErr != nil { + slog.Warn("deregister orphan route after rotation failure", + "reason", reason, "err", dErr, "orphan_route_id", regResp.RouteID) + } + } + options, err := ensurePeerOutboundsBypassVPN(regResp.ServerConfig) if err != nil { + cleanupNewRoute(err) return fmt.Errorf("patch sing-box options: %w", err) } @@ -542,34 +597,54 @@ func (c *Client) rotateCreds(ctx context.Context) error { c.mu.Unlock() if runCtx == nil { // Stop happened between the unlock above and here. Skip the - // build to avoid spinning up a libbox tied to a torn-down ctx. - // The new register row is harmless — server-side reaper will - // deprecate it after TTL since no heartbeat will arrive. + // build to avoid spinning up a libbox tied to a torn-down ctx, + // and clean up the just-created route so it doesn't linger. + cleanupNewRoute(errors.New("client stopped during rotation")) return errors.New("client stopped during rotation") } newBox, err := c.cfg.BuildBoxService(runCtx, options) if err != nil { + cleanupNewRoute(err) return fmt.Errorf("build new sing-box: %w", err) } // Close old, start new. Order matters — both want the same port. - // If newBox.Start fails after oldBox.Close, we lost the listener - // and the next heartbeat / rotation tick is the recovery point. + // If newBox.Start fails after oldBox.Close, retry briefly to absorb + // router-side TIME_WAIT / EADDRINUSE windows before giving up. if closeErr := oldBox.Close(); closeErr != nil { slog.Warn("close old box during rotation", "err", closeErr) } - if err := newBox.Start(); err != nil { + if err := startNewBoxWithRetry(ctx, newBox); err != nil { // Catastrophic: port is now unbound. Leave c.box pointing at // oldBox so a future Stop tries to close it (idempotent on - // already-closed); the next rotation tick will try again. + // already-closed); the next rotation tick will try again. Also + // deregister the now-orphan new route so the bandit doesn't + // hand its creds out for a non-listening port. + cleanupNewRoute(err) return fmt.Errorf("start new sing-box: %w", err) } + // Final swap under lock. Re-check active so a Stop racing us between + // runCtx-check above and now doesn't get resurrected by overwriting + // the cleared state Stop just set up. c.mu.Lock() + if !c.active { + c.mu.Unlock() + // Stop already cleared c.box / c.routeID. Close the new box we + // just brought up (Stop has no reference to it) and deregister + // the new route. The old route Stop already deregistered as + // part of its own teardown. + if err := newBox.Close(); err != nil { + slog.Warn("close new box after Stop raced rotation", "err", err) + } + cleanupNewRoute(errors.New("client stopped during rotation swap")) + return errors.New("client stopped during rotation") + } c.box = newBox c.routeID = regResp.RouteID c.boxOptions = options c.status.RouteID = regResp.RouteID + c.status.ExternalIP = externalIP c.mu.Unlock() // Deregister the prior route so the bandit stops handing the old @@ -588,6 +663,32 @@ func (c *Client) rotateCreds(ctx context.Context) error { return nil } +// startNewBoxWithRetry retries newBox.Start a handful of times with a +// short backoff to absorb router-side TIME_WAIT / EADDRINUSE between +// oldBox.Close releasing the port and newBox.Start re-binding it. Total +// wait is bounded under 1s so a healthy rotation isn't delayed +// noticeably; the alternative is leaving the peer's listener down for +// the full rotation interval (default 1h) on a transient bind failure. +func startNewBoxWithRetry(ctx context.Context, newBox boxService) error { + const attempts = 5 + backoff := 50 * time.Millisecond + var lastErr error + for i := 0; i < attempts; i++ { + if err := newBox.Start(); err == nil { + return nil + } else { + lastErr = err + } + select { + case <-ctx.Done(): + return fmt.Errorf("start new sing-box (ctx cancelled after %d attempts): %w", i+1, lastErr) + case <-time.After(backoff): + } + backoff *= 2 + } + return fmt.Errorf("start new sing-box (%d attempts): %w", attempts, lastErr) +} + // ensurePeerOutboundsBypassVPN guarantees the peer sing-box's outbound dials // bind to the physical interface rather than whatever the OS routing table // picks. Without this, when the user's own Lantern VPN is up its TUN holds diff --git a/peer/peer_test.go b/peer/peer_test.go index d1e635c1..72368077 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -647,9 +647,15 @@ func TestClient_RotatesCredentialsAtInterval(t *testing.T) { srv.registerCount.Load()) // Each rotation deregisters the prior route — N rotations => - // N deregisters (initial register is not preceded by one). + // N deregisters (initial register is not preceded by one). Wait + // briefly for the deregister to catch up to the most recent + // rotation; rotation issues the deregister after the swap-lock + // completes, so there's a small race window between observing the + // new register and the corresponding deregister landing. rotations := srv.registerCount.Load() - 1 - assert.GreaterOrEqual(t, srv.deregisterCount.Load(), rotations-1, + require.Eventually(t, func() bool { + return srv.deregisterCount.Load() >= rotations + }, 500*time.Millisecond, 25*time.Millisecond, "each rotation should deregister the prior route_id (got %d deregs vs %d rotations)", srv.deregisterCount.Load(), rotations) From 1c44635abd60b14c81893d9629c51390d5a6a876 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 23:47:03 -0600 Subject: [PATCH 32/63] peer: address Copilot review on #472 (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven follow-ups from the round-2 re-review: 1. startNewBoxWithRetry now wraps the libbox.Start call in a deferred recover that converts a panic into an error return. Without this, runRotation's recover one frame up catches the panic but only after rotateCreds' cleanupNewRoute path has been skipped — orphaning the freshly-registered route and leaving the port unbound. 2. Stop→Start race during rotation. The previous `!c.active` re-check at the swap lock missed the case where Stop cleared state AND a new Start re-set active=true before this rotation reached the swap; the old rotation would then overwrite the new session's box / routeID. rotateCreds now captures c.runCtx as sessionRunCtx at the top of the function and gates both the BuildBoxService call and the final swap on pointer-identity with the current c.runCtx — different ctx means a Stop→Start cycle replaced the session and our newBox / new route belong to the prior session, so close + deregister. 3. Post-swap deregister of the prior route now uses a fresh peerCleanupTimeout-bounded Background ctx instead of the rotation ctx. Stop cancelling that ctx between the swap and the deregister would have left the old (now-invalid) route in the server catalog until TTL — defeating the entire stale-credential cap the rotation is supposed to enforce. 4. startNewBoxWithRetry budget. The previous loop slept after every failed attempt, including the fifth (no point — we wouldn't try again), pushing total backoff to 1550ms when the comment claimed <1s. Now skips the final sleep, total budget 750ms (50+100+200+400 across 4 sleeps), comment updated to match. 5. peerCredRotationInterval doc dropped an engineering#3440 reference that violated AGENTS.md:13-17 (no ticket refs in code comments). Kept the rationale ('H2 leakage paths') without naming the issue. 6. runRotation doc dropped the explicit 'vpn/tunnel.go' reference — the 'the main tunnel start path already wraps it with recover for the same reason' framing carries the same context without naming the file. 7. TestClient_RotatesCredentialsAtInterval doc dropped the engineering#3437 ticket reference; rewords as 'pins the rotation invariant'. All tests pass under -race -count=1 across 5 consecutive runs. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 104 +++++++++++++++++++++++++++++++--------------- peer/peer_test.go | 8 ++-- 2 files changed, 75 insertions(+), 37 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index dfa77cf8..18eafc2a 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -121,8 +121,8 @@ type Client struct { // lantern-cloud (new route_id, new keypair, new shortID), rebuilds the // libbox service against the new options, and deregisters the prior // route. Caps blast radius from credential leakage (logs, telemetry, -// memory dumps, the H2 leakage path in engineering#3440) to ~1h -// regardless of peer process lifetime. +// memory dumps, H2 leakage paths) to ~1h regardless of peer process +// lifetime. // // Cost per rotation: one API.Register + Deregister round trip, one // libbox build + start + close cycle. Brief (~hundreds-of-ms) port- @@ -501,11 +501,11 @@ func (c *Client) credRotationLoop(ctx context.Context, interval time.Duration) { } // runRotation wraps rotateCreds with a panic recover. libbox.Start can -// panic (see the recover in vpn/tunnel.go's tunnel-start path); an -// unrecovered panic here would crash the host process during a -// background rotation, taking the user's main VPN with it. Treat a -// panic the same as any other rotation failure: log, keep the existing -// box serving, try again next tick. +// panic — the main tunnel start path already wraps it with recover for +// the same reason — and an unrecovered panic here would crash the host +// process during a background rotation, taking the user's main VPN +// with it. Treat a panic the same as any other rotation failure: log, +// keep the existing box serving, try again next tick. func (c *Client) runRotation(ctx context.Context) { defer func() { if r := recover(); r != nil { @@ -553,6 +553,14 @@ func (c *Client) rotateCreds(ctx context.Context) error { intPort := c.internalPort oldRouteID := c.routeID oldBox := c.box + // Capture sessionRunCtx upfront so the final swap can detect a + // Stop→Start cycle that happened mid-rotation. Just checking + // c.active at the swap isn't enough: Stop clears active, then a + // new Start can set it back to true before this rotation reaches + // the swap, and the old rotation would clobber the new session's + // state. The runCtx is unique per session, so a pointer-identity + // check at the swap is sufficient. + sessionRunCtx := c.runCtx c.mu.Unlock() if fwd == nil || oldBox == nil { @@ -593,16 +601,17 @@ func (c *Client) rotateCreds(ctx context.Context) error { } c.mu.Lock() - runCtx := c.runCtx + currentRunCtx := c.runCtx c.mu.Unlock() - if runCtx == nil { - // Stop happened between the unlock above and here. Skip the - // build to avoid spinning up a libbox tied to a torn-down ctx, - // and clean up the just-created route so it doesn't linger. + if currentRunCtx == nil || currentRunCtx != sessionRunCtx { + // Stop ran (runCtx==nil), or a Stop→Start cycle replaced the + // session (runCtx pointer differs from the one captured at the + // top). Either way the build below would tie a libbox to the + // wrong session; skip it and clean up the just-created route. cleanupNewRoute(errors.New("client stopped during rotation")) return errors.New("client stopped during rotation") } - newBox, err := c.cfg.BuildBoxService(runCtx, options) + newBox, err := c.cfg.BuildBoxService(sessionRunCtx, options) if err != nil { cleanupNewRoute(err) return fmt.Errorf("build new sing-box: %w", err) @@ -624,21 +633,26 @@ func (c *Client) rotateCreds(ctx context.Context) error { return fmt.Errorf("start new sing-box: %w", err) } - // Final swap under lock. Re-check active so a Stop racing us between - // runCtx-check above and now doesn't get resurrected by overwriting - // the cleared state Stop just set up. + // Final swap under lock. Re-check that the session is still the + // one we started against. Per-session runCtx identity is a stricter + // check than c.active alone: a Stop→Start cycle between the + // runCtx-check above and now would have cleared active AND set it + // back true, but the new session's runCtx differs from sessionRunCtx + // — so we'd otherwise resurrect old-session state into the new one. c.mu.Lock() - if !c.active { + if !c.active || c.runCtx != sessionRunCtx { c.mu.Unlock() - // Stop already cleared c.box / c.routeID. Close the new box we - // just brought up (Stop has no reference to it) and deregister - // the new route. The old route Stop already deregistered as - // part of its own teardown. + // Either Stop cleared state, or Stop→Start replaced the session. + // Close the new box we just brought up (the current session has + // no reference to it) and deregister the new route. Don't touch + // the prior route: in the Stop-only case, Stop already + // deregistered it; in the Stop→Start case, deregistering it + // would defeat the rotation point of cutting off the old creds. if err := newBox.Close(); err != nil { - slog.Warn("close new box after Stop raced rotation", "err", err) + slog.Warn("close new box after session changed during rotation", "err", err) } - cleanupNewRoute(errors.New("client stopped during rotation swap")) - return errors.New("client stopped during rotation") + cleanupNewRoute(errors.New("session changed during rotation swap")) + return errors.New("session changed during rotation") } c.box = newBox c.routeID = regResp.RouteID @@ -648,13 +662,17 @@ func (c *Client) rotateCreds(ctx context.Context) error { c.mu.Unlock() // Deregister the prior route so the bandit stops handing the old - // (now-invalid) creds to clients. Best-effort: the prior row will - // expire from its TTL anyway, but explicit deregister cuts the - // stale-creds window from up-to-TTL down to ~immediately. - if err := c.cfg.API.Deregister(ctx, oldRouteID); err != nil { + // (now-invalid) creds to clients. Use a fresh ctx so a Stop that + // races us between the swap above and the deregister doesn't cancel + // the cleanup — leaving the old (now-invalid-locally) route in the + // server catalog until TTL would defeat the rotation's stale-cred + // cap, which is the whole point of the feature. + deregCtx, cancelDereg := context.WithTimeout(context.Background(), peerCleanupTimeout) + if err := c.cfg.API.Deregister(deregCtx, oldRouteID); err != nil { slog.Warn("deregister prior route after rotation", "err", err, "old_route_id", oldRouteID) } + cancelDereg() slog.Info("peer cred rotation succeeded", "new_route_id", regResp.RouteID, @@ -665,11 +683,25 @@ func (c *Client) rotateCreds(ctx context.Context) error { // startNewBoxWithRetry retries newBox.Start a handful of times with a // short backoff to absorb router-side TIME_WAIT / EADDRINUSE between -// oldBox.Close releasing the port and newBox.Start re-binding it. Total -// wait is bounded under 1s so a healthy rotation isn't delayed -// noticeably; the alternative is leaving the peer's listener down for -// the full rotation interval (default 1h) on a transient bind failure. -func startNewBoxWithRetry(ctx context.Context, newBox boxService) error { +// oldBox.Close releasing the port and newBox.Start re-binding it. +// Inter-attempt backoff totals 750ms (50+100+200+400 across 4 sleeps; +// no sleep after the final attempt) so a healthy rotation isn't +// delayed noticeably; the alternative is leaving the peer's listener +// down for the full rotation interval (default 1h) on a transient +// bind failure. +// +// libbox.Start can panic; convert that to an error here rather than +// letting it propagate. Without this, the recover in runRotation would +// catch the panic but only after rotateCreds' cleanupNewRoute path +// has been skipped — leaving the freshly-registered route orphaned +// and the port unbound until next rotation. Returning the panic as +// an error lets rotateCreds' deferred cleanup deregister the orphan. +func startNewBoxWithRetry(ctx context.Context, newBox boxService) (retErr error) { + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("start new sing-box panicked: %v", r) + } + }() const attempts = 5 backoff := 50 * time.Millisecond var lastErr error @@ -679,6 +711,12 @@ func startNewBoxWithRetry(ctx context.Context, newBox boxService) error { } else { lastErr = err } + // Skip the sleep on the final attempt — we won't try again, + // so the wait is pure latency that would push total backoff + // above the documented sub-1s budget. + if i == attempts-1 { + break + } select { case <-ctx.Done(): return fmt.Errorf("start new sing-box (ctx cancelled after %d attempts): %w", i+1, lastErr) diff --git a/peer/peer_test.go b/peer/peer_test.go index 72368077..b4c9af2e 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -585,10 +585,10 @@ func TestAPIError_StringFormat(t *testing.T) { assert.Contains(t, e.Error(), "could not connect") } -// TestClient_RotatesCredentialsAtInterval pins the C2 fix from -// engineering#3437: the peer client must re-register and rebuild its -// libbox inbound on a schedule so a leaked credential's blast radius is -// bounded by CredRotationInterval rather than peer process lifetime. +// TestClient_RotatesCredentialsAtInterval pins the rotation invariant: +// the peer client must re-register and rebuild its libbox inbound on +// a schedule so a leaked credential's blast radius is bounded by +// CredRotationInterval rather than peer process lifetime. // // Drives a short rotation interval (50ms) and asserts: // 1. Multiple registers happen (start + ≥2 rotations within 250ms). From fe88fd74d58468ef0997fceba4fbee6eeac0837b Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 23:56:10 -0600 Subject: [PATCH 33/63] peer: address Copilot review on #472 (round 3) Two follow-ups from the round-3 re-review: 1. Test asserted against the internal c.routeID field directly. A future change that updated the routeID used by heartbeats but forgot to mirror it into Status.RouteID would still pass the test. Use CurrentStatus() so the assertion pins the observable contract. 2. rotateCreds docstring claimed 'On failure: the prior creds and box continue serving' across all failures, but a failure after oldBox.Close (startNewBoxWithRetry exhausts retries or panics) leaves the listener down until the next rotation tick rebinds. Narrow the docstring to distinguish failures before and after oldBox.Close; the router-side port mapping survives in both cases, only the in-process listener state differs. No behavior change. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 18 +++++++++++++++--- peer/peer_test.go | 9 +++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index 18eafc2a..270f25eb 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -524,9 +524,21 @@ func (c *Client) runRotation(ctx context.Context) { // success: a fresh route_id and keypair are in use, the libbox inbound // has been rebuilt against the new options, the prior route is // deregistered server-side, and the FlutterEvent stream sees no gap. -// On failure: the prior creds and box continue serving — rotation is -// best-effort. The router-side port mapping is preserved across the -// rotation; only the in-process samizdat state changes. +// +// On failure, behavior depends on where rotation aborted: +// - Before oldBox.Close (Register, options patch, BuildBoxService, +// stop-raced-rotation paths) — the prior box keeps serving with +// its existing creds; the newly-registered route (if any) is +// deregistered via cleanupNewRoute. +// - After oldBox.Close (startNewBoxWithRetry exhausts retries or +// panics) — the listener is down until the next rotation tick +// successfully rebinds. The router-side port mapping survives; +// only the in-process listener is gone. The new route is +// deregistered so the bandit doesn't hand its creds out for a +// non-listening port. +// +// In both cases the router-side port mapping is preserved; only the +// in-process samizdat state changes. // // Sequence: // 1. Re-register with the same (externalIP, externalPort) as Start. diff --git a/peer/peer_test.go b/peer/peer_test.go index b4c9af2e..96fc65a0 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -660,10 +660,11 @@ func TestClient_RotatesCredentialsAtInterval(t *testing.T) { srv.deregisterCount.Load(), rotations) // RouteID exposed via Status should reflect the latest rotation. - c.mu.Lock() - currentRouteID := c.routeID - c.mu.Unlock() - assert.NotEqual(t, "00000000-0000-0000-0000-000000000001", currentRouteID, + // Asserting against CurrentStatus() rather than the internal field + // pins the observable contract: a future change that updates the + // route used by heartbeats but forgets to mirror it into + // Status.RouteID would fail this assertion. + assert.NotEqual(t, "00000000-0000-0000-0000-000000000001", c.CurrentStatus().RouteID, "current route_id should have advanced past the initial register") // Multiple boxes built; first one closed. From 89c3f76d2da924b45fa07f25c46521e8dde5317b Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 00:04:50 -0600 Subject: [PATCH 34/63] peer: address Copilot review on #484 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings: 5 substantive validator bugs, 5 doc-lint violations. Substantive: 1. asStringSlice / asFloatSlice now accept scalar OR array. sing-box route rules encode single-element matches as a scalar (e.g. '"rule_set": "sr-direct"' or '"port": 25'), and treating only the array form as valid would false-positive a launch_cfg that uses the scalar form. Mirrors how sing-box itself decodes these fields. 2. Same for asFloatSlice — scalar number support. 3. New isUnconditionalReject helper. The old check counted any reject rule that mentioned an abuse tag, which would let through: - {action:reject, rule_set:[tag], invert:true} — inverted match rejects EVERYTHING EXCEPT the tag, not the tag. - {action:reject, rule_set:[tag], port:80} — narrows the reject to port-80 traffic only; abuse traffic on other ports passes. isUnconditionalReject(body, matchKey) requires action=reject, invert!=true, and no body keys outside {action, invert, matchKey}. Explicit invert=false is allowed (treated as the canonical no-op). 4. Same predicate-narrowing concern applies to the static-canary reject rules (RFC1918, SMTP). Updated validateStaticRejectCanaries to check each canary against isUnconditionalReject scoped to its own match field (ip_cidr or port). 5. New TestClient_Start_AbuseRuleValidationFailureUnwinds integration test mirrors the existing *FailureUnwinds tests: stub a launch_cfg without a route block, assert Start returns 'abuse-rule sanity check' error, c.IsActive() is false, the port forward is unmapped, the box was never started, and the route was deregistered. Doc-lint (AGENTS.md:13-17 forbids code-location and ticket refs): 6. validate.go:14 — dropped the explicit reference to lantern-cloud's server-side file; replaced with 'the server-side abuseTags list'. 7. validate.go:37 — same; 'the server-side static peer-egress-block list' replaces the path reference. 8. validate.go:50 — dropped 'engineering#TODO' placeholder. The supply-chain concern is real but unactionable as written; replaced with 'separate supply-chain concerns and are not in scope for this gate.' 9. peer.go:215 — dropped 'See validate.go for the exact checks'; replaced with 'the validator's docstring enumerates the exact rule shapes it requires.' 10. validate_test.go:13 — dropped peer_test.go reference; reworded as 'the stub server used in Start-path tests'. Six new tests added: - TestValidateAbuseRules_AcceptsScalarRuleSet — scalar matches valid - TestValidateAbuseRules_RejectsInverted — invert=true not credited - TestValidateAbuseRules_RejectsExtraConstraint — narrowed reject not credited - TestValidateAbuseRules_RejectsStaticCanaryWithExtraConstraint — same for canaries - TestValidateAbuseRules_AcceptsExplicitInvertFalse — explicit false credited - TestClient_Start_AbuseRuleValidationFailureUnwinds — Start-path unwind coverage All tests pass under -race -count=1. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 8 +-- peer/peer_test.go | 26 +++++++ peer/validate.go | 123 ++++++++++++++++++++++++--------- peer/validate_test.go | 153 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 268 insertions(+), 42 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index ca9cd4fa..027d662b 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -209,10 +209,10 @@ func (c *Client) Start(ctx context.Context) error { // Defence-in-depth: refuse to start the box if the server-supplied // launch_cfg is missing the expected abuse-handling rules. A - // server-side regression that silently shipped an open-proxy config - // would otherwise turn every peer in the field into one until the - // next deploy. The peer prefers failing to share over sharing - // unsafely. See validate.go for the exact checks. + // server-side regression that silently shipped an open-proxy + // config would otherwise turn every peer in the field into one + // until the next deploy. The peer prefers failing to share over + // sharing unsafely. if err := validateAbuseRules(regResp.ServerConfig); err != nil { return fmt.Errorf("launch_cfg failed abuse-rule sanity check: %w", err) } diff --git a/peer/peer_test.go b/peer/peer_test.go index 24ff1373..389aa4ac 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -421,6 +421,32 @@ func TestClient_Start_BoxStartFailureUnwinds(t *testing.T) { assert.Equal(t, int64(1), srv.deregisterCount.Load()) } +// A launch_cfg that fails the abuse-rule sanity check must unwind +// every resource Start has taken so far — port forward, registration +// — without ever building or starting the box. This is the +// defence-in-depth gate that keeps a server-side regression from +// turning every peer into an open proxy. +func TestClient_Start_AbuseRuleValidationFailureUnwinds(t *testing.T) { + fwd := &fakeForwarder{externalIP: "203.0.113.42"} + box := &fakeBoxService{} + srv := newStubServer(t) + // Strip the abuse rules from the launch_cfg the stub returns — + // just the inbound, no route block. validateAbuseRules will reject + // this as "missing route block". + srv.registerResp.ServerConfig = `{"inbounds":[{"type":"samizdat","tag":"samizdat-in"}]}` + c := newTestClient(t, fwd, box, srv) + + err := c.Start(context.Background()) + require.Error(t, err) + assert.ErrorContains(t, err, "abuse-rule sanity check") + + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped(), "validation failure must unmap the port forward") + assert.False(t, box.started.Load(), "validation must run before box.Start") + assert.False(t, box.closed.Load(), "box was never started, nothing to close") + assert.Equal(t, int64(1), srv.deregisterCount.Load(), "validation failure must deregister the route we just registered") +} + func TestClient_Stop_HappyPath(t *testing.T) { fwd := &fakeForwarder{} box := &fakeBoxService{} diff --git a/peer/validate.go b/peer/validate.go index e9d34434..4810effd 100644 --- a/peer/validate.go +++ b/peer/validate.go @@ -6,12 +6,12 @@ import ( "fmt" ) -// abuseRuleSetTags is the canonical list of abuse rule_set tags that the -// peer launch_cfg MUST carry. Mirrors abuseTags in -// lantern-cloud/cmd/api/pcfg/samizdat.go. If samizdat.go grows or -// renames a tag, this list grows with it — the test in -// lantern-cloud asserts the server side; this list asserts the client -// side sees the same shape after registration. +// abuseRuleSetTags is the canonical list of abuse rule_set tags that +// the peer launch_cfg MUST carry. Mirrors the server-side abuseTags +// list that emits the rule_set entries into the registration response. +// If the server-side list grows or renames a tag, this list grows +// with it — the server-side test asserts the emit side; this list +// asserts the client side sees the same shape after registration. var abuseRuleSetTags = []string{ "geosite-malware", "geoip-malware", @@ -20,10 +20,10 @@ var abuseRuleSetTags = []string{ } // rfc1918CanaryCIDR and smtpCanaryPort are sentinel values that, if -// missing from the launch_cfg's reject rules, indicate the static -// peerEgressBlockRules block in samizdat.go was dropped or mutated. -// We pick one IP-CIDR and one port from each block as a low-cost smoke -// test; a full structural check would be brittle to upstream additions. +// missing from the launch_cfg's reject rules, indicate the server- +// side static peer-egress-block list was dropped or mutated. We pick +// one IP-CIDR and one port from each block as a low-cost smoke test; +// a full structural check would be brittle to upstream additions. const ( rfc1918CanaryCIDR = "10.0.0.0/8" smtpCanaryPort = float64(25) @@ -33,21 +33,21 @@ const ( // options returned by /v1/peer/register. The server is supposed to // embed a set of route.rule_set + route.rules entries that block the // peer from forwarding traffic to known-malicious destinations, -// RFC1918 CIDRs, and abuse-prone ports. Those rules live in -// lantern-cloud/cmd/api/pcfg/samizdat.go. +// RFC1918 CIDRs, and abuse-prone ports. // -// If a future regression in that server-side file ships a launch_cfg -// without those rules, every newly-registered peer would silently turn -// into an open residential proxy until someone noticed. This validator -// blocks Start before libbox runs an unsafe config; the peer prefers -// to fail to share at all rather than share unsafely. +// If a future server-side regression ships a launch_cfg without those +// rules, every newly-registered peer would silently turn into an open +// residential proxy until someone noticed. This validator blocks +// Start before libbox runs an unsafe config; the peer prefers to fail +// to share at all rather than share unsafely. // // The check is structural-only — it confirms the expected rule_set -// tags appear in both route.rule_set and route.rules (as a reject -// action), plus two canary entries from the static reject block. It -// does NOT verify the .srs files at the rule_set URLs are uncorrupted -// or that the URLs themselves are trustworthy; those are separate -// supply-chain concerns tracked in engineering#TODO. +// tags appear in both route.rule_set and route.rules (as an +// unconditional reject), plus two canary entries from the static +// reject block. It does NOT verify the .srs files at the rule_set +// URLs are uncorrupted or that the URLs themselves are trustworthy; +// those are separate supply-chain concerns and are not in scope for +// this gate. func validateAbuseRules(optionsJSON string) error { var raw map[string]any if err := json.Unmarshal([]byte(optionsJSON), &raw); err != nil { @@ -102,6 +102,12 @@ func validateAbuseRuleSetTags(route map[string]any) error { // has a matching reject rule in route.rules. A rule_set without a // matching reject is a no-op — sing-box downloads the list and does // nothing with it. +// +// Only counts *unconditional* rejects (see isUnconditionalReject): +// a reject rule with an extra match constraint (port, domain, source +// IP, etc.) or with invert=true would let traffic in the abuse list +// through under most conditions; counting it as covering the tag +// would mask a misconfigured launch_cfg. func validateAbuseRejectRules(route map[string]any) error { rules, _ := route["rules"].([]any) rejectedTags := map[string]bool{} @@ -110,7 +116,7 @@ func validateAbuseRejectRules(route map[string]any) error { if body == nil { continue } - if action, _ := body["action"].(string); action != "reject" { + if !isUnconditionalReject(body, "rule_set") { continue } for _, t := range asStringSlice(body["rule_set"]) { @@ -124,7 +130,7 @@ func validateAbuseRejectRules(route map[string]any) error { } } if len(missing) > 0 { - return fmt.Errorf("route.rules has no reject action for abuse tags: %v (rule_sets would download but not block)", missing) + return fmt.Errorf("route.rules has no unconditional reject for abuse tags: %v (rule_sets would download but not unconditionally block)", missing) } return nil } @@ -143,17 +149,22 @@ func validateStaticRejectCanaries(route map[string]any) error { if body == nil { continue } - if action, _ := body["action"].(string); action != "reject" { - continue - } - for _, cidr := range asStringSlice(body["ip_cidr"]) { - if cidr == rfc1918CanaryCIDR { - gotRFC1918 = true + // Each canary is checked against an unconditional reject scoped + // to its own match field. A reject that ANDs ip_cidr with a port + // or domain (or sets invert) would not actually cover the + // destination class the canary represents, so don't credit it. + if isUnconditionalReject(body, "ip_cidr") { + for _, cidr := range asStringSlice(body["ip_cidr"]) { + if cidr == rfc1918CanaryCIDR { + gotRFC1918 = true + } } } - for _, p := range asFloatSlice(body["port"]) { - if p == smtpCanaryPort { - gotSMTP = true + if isUnconditionalReject(body, "port") { + for _, p := range asFloatSlice(body["port"]) { + if p == smtpCanaryPort { + gotSMTP = true + } } } } @@ -170,6 +181,38 @@ func validateStaticRejectCanaries(route map[string]any) error { return nil } +// isUnconditionalReject reports whether the rule body is a reject +// action whose scope is defined solely by matchKey — no other match +// fields and no invert. matchKey is the field expected to carry the +// rule's scope ("rule_set", "ip_cidr", or "port"). +// +// The pure-reject shape we want for each abuse-block category is: +// +// {"action": "reject", "": [...]} // canonical +// {"action": "reject", "": [...], "invert": false} // explicit no-op +// +// Anything else either narrows the match (e.g. adding "port": 80 +// to a rule_set reject limits it to port-80 traffic) or inverts the +// match (invert=true rejects everything OUTSIDE the matchKey). In +// both cases the launch_cfg would not actually block the abuse +// destination class the rule claims to cover, so callers must not +// credit it as covering the tag. +func isUnconditionalReject(body map[string]any, matchKey string) bool { + if action, _ := body["action"].(string); action != "reject" { + return false + } + if invert, _ := body["invert"].(bool); invert { + return false + } + allowed := map[string]bool{"action": true, "invert": true, matchKey: true} + for k := range body { + if !allowed[k] { + return false + } + } + return true +} + // ruleBody returns the field-bearing inner object of a sing-box // route Rule. sing-box marshals "default" rules in two equivalent // shapes: inlined at the top level (no "default" wrapper) or nested @@ -185,7 +228,15 @@ func ruleBody(r any) map[string]any { return m } +// asStringSlice normalizes a sing-box rule field that can be encoded +// as either a scalar string or a string array. Both forms appear in +// practice: `{"rule_set": "sr-direct"}` and `{"rule_set": ["a","b"]}` +// are equivalent at the route layer. Treating only the array form as +// valid here would false-positive a launch_cfg that emits the scalar. func asStringSlice(v any) []string { + if s, ok := v.(string); ok { + return []string{s} + } arr, ok := v.([]any) if !ok { return nil @@ -199,7 +250,13 @@ func asStringSlice(v any) []string { return out } +// asFloatSlice is the numeric counterpart of asStringSlice — fields +// like `port` can come back as `25` or `[25, 587]`. JSON unmarshals +// every number to float64, so the canary comparison uses float64 too. func asFloatSlice(v any) []float64 { + if f, ok := v.(float64); ok { + return []float64{f} + } arr, ok := v.([]any) if !ok { return nil diff --git a/peer/validate_test.go b/peer/validate_test.go index 723ec0ef..2a306b57 100644 --- a/peer/validate_test.go +++ b/peer/validate_test.go @@ -6,11 +6,11 @@ import ( ) // minimalValidLaunchCfg returns a launch_cfg JSON that passes -// validateAbuseRules: the four abuse rule_set tags from -// lantern-cloud's samizdat.go (each as a "remote" rule_set + a -// matching reject rule), plus one RFC1918 and one SMTP canary in -// reject rules. Shared by peer_test.go's stubServer so the existing -// Start-path tests do not regress on the new check. +// validateAbuseRules: the four abuse rule_set tags (each as a +// "remote" rule_set + a matching unconditional reject rule), plus +// one RFC1918 and one SMTP canary in reject rules. Shared by the +// stub server used in Start-path tests so the existing tests do not +// regress on the new check. const minimalValidLaunchCfg = `{ "inbounds":[{"type":"samizdat","tag":"samizdat-in"}], "route":{ @@ -181,3 +181,146 @@ func TestValidateAbuseRules_BadJSON(t *testing.T) { t.Errorf("error should mention JSON parse failure, got: %v", err) } } + +// sing-box accepts both `"rule_set": "tag"` (scalar) and +// `"rule_set": ["tag"]` (array) — the validator must too, otherwise +// a perfectly valid launch_cfg that happens to use the scalar form +// would be flagged as missing the tag. +func TestValidateAbuseRules_AcceptsScalarRuleSet(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":"geosite-malware"}, + {"action":"reject","rule_set":"geoip-malware"}, + {"action":"reject","rule_set":"geosite-phishing"}, + {"action":"reject","rule_set":"geosite-cryptominers"}, + {"action":"reject","ip_cidr":"10.0.0.0/8"}, + {"action":"reject","port":25} + ] + }}` + if err := validateAbuseRules(cfg); err != nil { + t.Fatalf("scalar rule_set / ip_cidr / port should be valid, got: %v", err) + } +} + +// A reject rule with invert=true rejects everything EXCEPT the +// listed match — the opposite of what an abuse-block rule should do. +// It must not satisfy the abuse-tag check. +func TestValidateAbuseRules_RejectsInverted(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"],"invert":true}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"]}, + {"action":"reject","port":[25]} + ] + }}` + err := validateAbuseRules(cfg) + if err == nil { + t.Fatal("inverted reject must not satisfy the abuse-tag check") + } + if !strings.Contains(err.Error(), "geosite-malware") { + t.Errorf("error should call out the inverted tag, got: %v", err) + } +} + +// A reject rule that ANDs the abuse rule_set with another constraint +// (port, domain, source IP, etc.) only fires for the intersection — +// most abuse-list traffic still passes. Must not count as covering +// the tag. +func TestValidateAbuseRules_RejectsExtraConstraint(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"],"port":[80]}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"]}, + {"action":"reject","port":[25]} + ] + }}` + err := validateAbuseRules(cfg) + if err == nil { + t.Fatal("reject with extra constraint must not satisfy the abuse-tag check") + } + if !strings.Contains(err.Error(), "geosite-malware") { + t.Errorf("error should call out the constrained tag, got: %v", err) + } +} + +// Same predicate-narrowing concern applies to the static canary +// reject rules. An ip_cidr reject ANDed with a port no longer +// covers all RFC1918 traffic and must not count as the canary. +func TestValidateAbuseRules_RejectsStaticCanaryWithExtraConstraint(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"]}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"],"port":[80]}, + {"action":"reject","port":[25]} + ] + }}` + err := validateAbuseRules(cfg) + if err == nil { + t.Fatal("RFC1918 canary ANDed with port must not satisfy the static-block check") + } + if !strings.Contains(err.Error(), "RFC1918") { + t.Errorf("error should call out the missing RFC1918 canary, got: %v", err) + } +} + +// Explicit invert=false should be treated as the canonical no-op +// (sing-box's default is false) and still credit the rule. +func TestValidateAbuseRules_AcceptsExplicitInvertFalse(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"],"invert":false}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"]}, + {"action":"reject","port":[25]} + ] + }}` + if err := validateAbuseRules(cfg); err != nil { + t.Fatalf("explicit invert=false should be treated as a pure reject, got: %v", err) + } +} From 06ceb1fa61c7fedf07b710af920a8dd0163dbf36 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 00:46:34 -0600 Subject: [PATCH 35/63] peer/events: address Copilot review on #499 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four substantive findings (the fifth — local replace directive in go.mod — was already addressed in the cascade rebase to 3debe39): 1. ConnectionEvent now carries a Timestamp field (Unix millis at emit time). Consumers that need ordering across the async dispatch path, or that aggregate over a time window, can compare it directly without snapshotting wall time at receive. 2. Per-connection forwarding log dropped from Info to Debug. Two reasons: under real traffic the Info-level breadcrumb floods logs, and the remote ip:port doesn't belong in routinely-collected client logs for a censorship-circumvention tool. Operators investigating 'no globe arcs despite samizdat traffic' can flip the level. The once-per-session listener-registration line stays at Info — that's a lifecycle event, not a per-connection breadcrumb. 3. events.Emit's diagnostic hook (emitDebugLogger) now defaults to a no-op instead of a synchronous log.Printf, and is called AFTER releasing subscriptionsMu rather than under the RLock. The old default both spammed prod logs and let the logger amplify subscriptionsMu contention on hot event types. Added SetEmitDebugLogger so callers (tests, diagnostic builds) can swap in a real logger when investigating a specific path; nil restores the no-op. The 'log' import is no longer needed. 4. TestClient_StatusEventEmittedOnStartAndStop rewritten to assert set-membership of expected phases + the final-state contract (PhaseServing carries Active=true, RouteID set; all others carry Active=false). events.Emit dispatches each subscriber's callback in its own goroutine, so the channel-arrival order of multiple sequential Emits is non-deterministic and the previous strict- ordered receive was inherently flaky. New drainPhases helper collects N events keyed by Phase (last-write-per-phase wins). Tests pass 5/5 times under -race -count=1. Co-Authored-By: Claude Opus 4.7 --- events/events.go | 34 +++++++++++-------- peer/peer.go | 31 +++++++++++------ peer/peer_test.go | 86 +++++++++++++++++++++++++++++++++-------------- 3 files changed, 101 insertions(+), 50 deletions(-) diff --git a/events/events.go b/events/events.go index a5e37cd4..66219455 100644 --- a/events/events.go +++ b/events/events.go @@ -28,7 +28,6 @@ package events import ( "context" - stdlog "log" "reflect" "sync" "sync/atomic" @@ -120,15 +119,13 @@ func (e *Subscription[T]) Unsubscribe() { // asynchronously in separate goroutines. func Emit[T Event](evt T) { subscriptionsMu.RLock() - defer subscriptionsMu.RUnlock() key := reflect.TypeFor[T]() subs, ok := subscriptions[key] - // Diagnostic: surfaces the subscriber count at emit time so a missing - // FlutterEvent on the consumer side is distinguishable from "no - // subscribers registered for this type" vs "subscribers registered - // but callback panics silently." Spam-friendly when traffic spikes, - // but we're investigating a zero-callback path so the noise is - // short-lived; remove (or downgrade to Debug) once the chain works. + subscriptionsMu.RUnlock() + // Diagnostic hook; default no-op so high-frequency event types don't + // flood logs in prod. Tests / debugging swap in a real logger via + // SetEmitDebugLogger. Called after releasing subscriptionsMu so a + // blocking logger can't amplify lock contention on hot event types. emitDebugLogger(key, len(subs)) if !ok { return @@ -138,10 +135,19 @@ func Emit[T Event](evt T) { } } -// emitDebugLogger is a package-level var so tests can suppress the -// per-emit log, and so prod can swap in slog. Default uses Go's stdlib -// log so events package doesn't need to import slog (and avoid a cycle -// with anything that imports events for its own log forwarding). -var emitDebugLogger = func(key reflect.Type, subCount int) { - stdlog.Printf("events.Emit type=%s subscribers=%d", key, subCount) +// emitDebugLogger is invoked once per Emit with the event type and +// current subscriber count. Default is a no-op; callers (tests, +// diagnostic builds) swap in a real logger via SetEmitDebugLogger. +var emitDebugLogger = func(reflect.Type, int) {} + +// SetEmitDebugLogger replaces the no-op diagnostic hook for the +// duration of an investigation (e.g., tracking "events vanish" paths). +// Pass nil to restore the no-op default. Safe to call from main / +// init; not safe to call concurrently with Emit on the hot path. +func SetEmitDebugLogger(fn func(eventType reflect.Type, subscriberCount int)) { + if fn == nil { + emitDebugLogger = func(reflect.Type, int) {} + return + } + emitDebugLogger = fn } diff --git a/peer/peer.go b/peer/peer.go index fb8cf8c2..c9b87281 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -66,14 +66,19 @@ type StatusEvent struct { // ConnectionEvent fires every time a remote client opens or closes a // samizdat session against the local peer's inbound. Source carries the // remote "ip:port" string; consumers (the globe view, abuse aggregation) -// extract the IP for geo-lookup or rate-limit attribution. +// extract the IP for geo-lookup or rate-limit attribution. Timestamp +// is the emit time in Unix millis; consumers that aggregate across a +// time window or that need to order events when the underlying +// dispatch is async can compare it directly. // -// State +1 on accept, -1 on close -// Source remote peer "ip:port" +// State +1 on accept, -1 on close +// Source remote peer "ip:port" +// Timestamp emit time in Unix milliseconds type ConnectionEvent struct { events.Event - State int `json:"state"` - Source string `json:"source"` + State int `json:"state"` + Source string `json:"source"` + Timestamp int64 `json:"timestamp"` } // Port range chosen to minimize collision risk on the typical home network, @@ -389,13 +394,17 @@ func (c *Client) Start(ctx context.Context) (retErr error) { "state", state, "source", source) return } - // One-line breadcrumb per accept/close so we can correlate samizdat-in - // activity with peer-connection FlutterEvents on the consumer side - // — without this, "no globe arcs despite samizdat traffic" is - // indistinguishable from "events fire but the bridge swallows them." - slog.Info("peer listener: forwarding connection event", + // Per-connection breadcrumb correlates samizdat-in activity with + // peer-connection FlutterEvents on the consumer side. Debug-level + // so prod logs aren't flooded under real traffic and so the + // remote ip:port doesn't land in routinely-collected client logs; + // operators investigating "no globe arcs despite samizdat traffic" + // can flip the level. The listener-registration line below stays + // at Info — that's a once-per-session lifecycle event, not a + // per-connection breadcrumb. + slog.Debug("peer listener: forwarding connection event", "state", state, "source", source) - events.Emit(ConnectionEvent{State: state, Source: source}) + events.Emit(ConnectionEvent{State: state, Source: source, Timestamp: time.Now().UnixMilli()}) }) slog.Info("peer listener: registered with peerconn", "route_id", regResp.RouteID) diff --git a/peer/peer_test.go b/peer/peer_test.go index 29e9c0fe..eb392464 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -710,39 +710,75 @@ func TestClient_StatusEventEmittedOnStartAndStop(t *testing.T) { require.NoError(t, c.Start(context.Background())) - wantStartPhases := []Phase{ - PhaseMappingPort, - PhaseDetectingIP, - PhaseRegistering, - PhaseStartingBox, - PhaseVerifying, - PhaseServing, - } - for _, want := range wantStartPhases { - select { - case evt := <-got: - assert.Equal(t, want, evt.Status.Phase, "wrong phase in Start sequence") - if want == PhaseServing { - assert.True(t, evt.Status.Active, "active must be true on serving") - assert.NotEmpty(t, evt.Status.RouteID, "route_id must be set on serving") - } else { - assert.False(t, evt.Status.Active, "active must be false on intermediate phase %q", want) - } - case <-time.After(time.Second): - t.Fatalf("no Start status event for phase %q within 1s", want) + // events.Emit dispatches each callback in a separate goroutine, so + // the order events land on the channel isn't deterministic — assert + // set-membership of the expected phases + the final-state contract + // (the only state observers actually care about) rather than the + // sequence. + wantStartPhases := map[Phase]bool{ + PhaseMappingPort: true, + PhaseDetectingIP: true, + PhaseRegistering: true, + PhaseStartingBox: true, + PhaseVerifying: true, + PhaseServing: true, + } + startEvents := drainPhases(t, got, len(wantStartPhases)) + for want := range wantStartPhases { + assert.Contains(t, startEvents, want, "Start sequence missing phase %q", want) + } + servingEvt, ok := startEvents[PhaseServing] + require.True(t, ok, "Start sequence must reach PhaseServing") + assert.True(t, servingEvt.Status.Active, "active must be true on serving") + assert.NotEmpty(t, servingEvt.Status.RouteID, "route_id must be set on serving") + for phase, evt := range startEvents { + if phase == PhaseServing { + continue } + assert.False(t, evt.Status.Active, "active must be false on intermediate phase %q", phase) } require.NoError(t, c.Stop(context.Background())) - for _, want := range []Phase{PhaseStopping, PhaseIdle} { + wantStopPhases := map[Phase]bool{ + PhaseStopping: true, + PhaseIdle: true, + } + stopEvents := drainPhases(t, got, len(wantStopPhases)) + for want := range wantStopPhases { + assert.Contains(t, stopEvents, want, "Stop sequence missing phase %q", want) + } + for phase, evt := range stopEvents { + assert.False(t, evt.Status.Active, "active must be false during stop (phase %q)", phase) + } +} + +// drainPhases reads up to n StatusEvents from got and returns them +// keyed by Phase (last event per phase wins). Used by tests that need +// set-membership semantics rather than strict ordering because +// events.Emit's per-callback goroutines deliver out of order under +// the runtime's scheduling. +func drainPhases(t *testing.T, got <-chan StatusEvent, n int) map[Phase]StatusEvent { + t.Helper() + out := make(map[Phase]StatusEvent, n) + deadline := time.After(2 * time.Second) + for i := 0; i < n; i++ { select { case evt := <-got: - assert.Equal(t, want, evt.Status.Phase, "wrong phase in Stop sequence") - assert.False(t, evt.Status.Active, "active must be false during stop") - case <-time.After(time.Second): - t.Fatalf("no Stop status event for phase %q within 1s", want) + out[evt.Status.Phase] = evt + case <-deadline: + t.Fatalf("received only %d/%d status events within 2s; got phases: %v", + i, n, mapKeys(out)) } } + return out +} + +func mapKeys[K comparable, V any](m map[K]V) []K { + keys := make([]K, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys } // TestClient_StatusEventOnStartError surfaces a Start failure to the UI From 6fc208b7913d817c6d60a86a5f4f556787b02128 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 15:25:58 -0600 Subject: [PATCH 36/63] events: snapshot subscribers under RLock before iterating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1's fix to move emitDebugLogger out from under subscriptionsMu.RLock (so a blocking logger couldn't amplify lock contention) accidentally moved the per-subscriber iteration outside the lock too. Iterating the subscriptions[key] map after RUnlock races against Unsubscribe's write-locked mutation — guaranteed 'concurrent map iteration and map write' panic under load. Fix: snapshot the callbacks into a slice while holding the RLock, then drop the lock and run emitDebugLogger + the per-callback goroutine spawns over the slice. Slice iteration is race-free because the slice itself is unshared. The original code (pre-round-1) was correct because it held the RLock for the whole function via defer — that's still safe but it forces logger calls to run under the lock. The snapshot pattern gets both properties: no iteration race + no blocking under the lock. Co-Authored-By: Claude Opus 4.7 --- events/events.go | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/events/events.go b/events/events.go index 66219455..0e84470f 100644 --- a/events/events.go +++ b/events/events.go @@ -118,19 +118,25 @@ func (e *Subscription[T]) Unsubscribe() { // Emit notifies all subscribers of the event, passing event data. Callbacks are invoked // asynchronously in separate goroutines. func Emit[T Event](evt T) { - subscriptionsMu.RLock() key := reflect.TypeFor[T]() - subs, ok := subscriptions[key] - subscriptionsMu.RUnlock() - // Diagnostic hook; default no-op so high-frequency event types don't - // flood logs in prod. Tests / debugging swap in a real logger via - // SetEmitDebugLogger. Called after releasing subscriptionsMu so a - // blocking logger can't amplify lock contention on hot event types. - emitDebugLogger(key, len(subs)) - if !ok { - return + // Snapshot the callbacks into a slice under the RLock, then drop + // the lock before doing anything that could block (the diagnostic + // log, the per-callback goroutine spawn). Iterating the underlying + // map after releasing the lock would race against Unsubscribe's + // write lock — `concurrent map iteration and map write` panic + // territory under load. + subscriptionsMu.RLock() + subsMap := subscriptions[key] + cbs := make([]func(any), 0, len(subsMap)) + for _, cb := range subsMap { + cbs = append(cbs, cb) } - for _, cb := range subs { + subscriptionsMu.RUnlock() + // Diagnostic hook; default no-op so high-frequency event types + // don't flood logs in prod. Tests / debugging swap in a real + // logger via SetEmitDebugLogger. + emitDebugLogger(key, len(cbs)) + for _, cb := range cbs { go cb(evt) } } From 0e1470d8b7a7a8c986ed38a879f664980e400d69 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 14:28:47 -0600 Subject: [PATCH 37/63] peer: read PeerManualPortKey setting alongside RADIANCE_PEER_EXTERNAL_PORT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds settings.PeerManualPortKey so the user-facing Advanced UI can persist the manual port forward without an env var. Resolution order in peer.Client.Start's NewForwarder: 1. settings.PeerManualPortKey (Advanced UI in lantern Flutter) 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / power-user) 3. UPnP discovery (default) The setting is wired through lantern-core's PatchSettings(PeerShareEnabledKey...) path on a separate branch — the new `setPeerManualPort` FFI export over there calls PatchSettings({PeerManualPortKey: }) which lands in radiance's settings store and gets picked up on the next peer.Client.Start. Co-Authored-By: Claude Opus 4.7 (1M context) --- common/settings/settings.go | 7 +++++++ peer/peer.go | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/common/settings/settings.go b/common/settings/settings.go index 3a3afd90..1c5e5cb1 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -56,6 +56,13 @@ const ( AdBlockKey _key = "ad_block" // bool AutoConnectKey _key = "auto_connect" // bool PeerShareEnabledKey _key = "peer_share_enabled" // bool + // PeerManualPortKey is the TCP port number the user has manually + // forwarded on their router (single-port 1:1 NAT). When non-zero, + // peer.Client.Start uses portforward.ManualForwarder with this port + // instead of probing UPnP. Surfaced as an Advanced setting in the + // Share My Connection UI for users on networks where UPnP is + // disabled or unavailable. + PeerManualPortKey _key = "peer_manual_port" // int (0 = use UPnP) SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] diff --git a/peer/peer.go b/peer/peer.go index c9b87281..0aec7b9e 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -17,6 +17,7 @@ import ( box "github.com/getlantern/lantern-box" "github.com/getlantern/lantern-box/tracker/peerconn" "github.com/getlantern/radiance/common/env" + "github.com/getlantern/radiance/common/settings" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -209,9 +210,28 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { + // Manual port-forward override. Use case: networks where + // UPnP is disabled or unavailable (router has UPnP off for + // security, ISP-provided gateways without IGD, networks + // behind double-NAT) but the user has manually configured + // a port forward on their router. We trust the user's + // config — no UPnP roundtrip — and report the configured + // port as both the external and internal port (the 1:1 + // case every consumer router exposes). + // + // Resolution order: + // 1. settings.PeerManualPortKey (Advanced UI) + // 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / + // power-user override) + // 3. fall through to UPnP discovery + if port := uint16(settings.GetInt(settings.PeerManualPortKey)); port != 0 { + slog.Info("peer client using manual port forward", + "port", port, "source", "setting") + return &manualPortForwarder{port: port}, nil + } if p := manualPort(); p != 0 { slog.Info("peer client using manual port forward", - "port", p, "env", env.PeerExternalPort.String()) + "port", p, "source", env.PeerExternalPort.String()) return &manualPortForwarder{port: p}, nil } // Explicitly return a nil interface on error — `return From b49872c1560d172e139c46554b63a06c540aea6d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 29 May 2026 15:14:28 -0600 Subject: [PATCH 38/63] portforward: extract manual port forwarder to its own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual port forwarder landed in peer/peer.go via #466 (commit a342889) to support routers without UPnP. Move it to the portforward package alongside the UPnP-based Forwarder so every portForwarder implementation lives in one place. Net zero functional change, just relocation: peer/peer.go - manualPortForwarder type + 4 method receivers - manualPort() env-parser helper - 'strconv' import (no longer needed) + NewForwarder closure now calls portforward.NewManualForwarder / portforward.ParseManualPort peer/peer_test.go - TestManualPort + TestManualPortForwarder (moved out of peer pkg) portforward/manual.go (new) + ManualForwarder + NewManualForwarder + ParseManualPort (the env-parser, factored out so callers can decide whether to log + fall through or treat as a hard error) + MapPort/UnmapPort/StartRenewal/ExternalIP methods + 'manual' method tag (was 'manual-env'; dropped the -env suffix since this implementation now serves both env and setting paths) portforward/manual_test.go (new) + TestParseManualPort (9 input cases — boundaries, invalid, empty) + TestManualForwarder (full portForwarder contract) The peer package retains the portForwarder *interface* — that's where peer expresses what it needs from a forwarder; the concrete implementations live in portforward. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 52 ++++++---------------------- peer/peer_test.go | 52 ---------------------------- portforward/manual.go | 71 ++++++++++++++++++++++++++++++++++++++ portforward/manual_test.go | 66 +++++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 93 deletions(-) create mode 100644 portforward/manual.go create mode 100644 portforward/manual_test.go diff --git a/peer/peer.go b/peer/peer.go index 0aec7b9e..cdde6f7d 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "math/rand/v2" - "strconv" "sync" "sync/atomic" "time" @@ -22,41 +21,6 @@ import ( "github.com/getlantern/radiance/portforward" ) -// manualPortForwarder satisfies the portForwarder interface without doing -// any UPnP work. Used when env.PeerExternalPort is set. -type manualPortForwarder struct{ port uint16 } - -func (m *manualPortForwarder) MapPort(_ context.Context, _ uint16, _ string) (*portforward.Mapping, error) { - return &portforward.Mapping{ - ExternalPort: m.port, - InternalPort: m.port, - Method: "manual-env", - }, nil -} -func (m *manualPortForwarder) UnmapPort(_ context.Context) error { return nil } -func (m *manualPortForwarder) StartRenewal(_ context.Context) {} -func (m *manualPortForwarder) ExternalIP(_ context.Context) (string, error) { - // An empty external IP signals the server to use the address it - // observed on the inbound request — when the user has supplied a - // manual port but no WAN IP, the server's view is the right answer. - return "", nil -} - -// manualPort returns the parsed env.PeerExternalPort value, or 0 if unset -// or invalid. -func manualPort() uint16 { - raw := env.GetString(env.PeerExternalPort) - if raw == "" { - return 0 - } - p, err := strconv.Atoi(raw) - if err != nil || p < 1 || p > 65535 { - slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), "value", raw) - return 0 - } - return uint16(p) -} - // StatusEvent fires whenever the Client's session state changes — successful // Start, user Stop, or auto-Stop on a 404 heartbeat. type StatusEvent struct { @@ -227,12 +191,18 @@ func NewClient(cfg Config) (*Client, error) { if port := uint16(settings.GetInt(settings.PeerManualPortKey)); port != 0 { slog.Info("peer client using manual port forward", "port", port, "source", "setting") - return &manualPortForwarder{port: port}, nil + return portforward.NewManualForwarder(port), nil } - if p := manualPort(); p != 0 { - slog.Info("peer client using manual port forward", - "port", p, "source", env.PeerExternalPort.String()) - return &manualPortForwarder{port: p}, nil + if raw := env.GetString(env.PeerExternalPort); raw != "" { + port, err := portforward.ParseManualPort(raw) + if err != nil { + slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), + "value", raw, "error", err) + } else { + slog.Info("peer client using manual port forward", + "port", port, "source", env.PeerExternalPort.String()) + return portforward.NewManualForwarder(port), nil + } } // Explicitly return a nil interface on error — `return // portforward.NewForwarder(ctx)` collapses the (*Forwarder, error) diff --git a/peer/peer_test.go b/peer/peer_test.go index eb392464..162ab7ad 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -627,58 +627,6 @@ func TestPickInternalPort_InRange(t *testing.T) { } } -// manualPort parses the RADIANCE_PEER_EXTERNAL_PORT env var. Unset, empty, -// non-numeric, and out-of-range values all collapse to 0, which the -// NewClient default factory treats as "no override → use UPnP discovery". -// Only a 1..65535 value selects the manual path. -func TestManualPort(t *testing.T) { - tests := []struct { - name string - env string - want uint16 - }{ - {"unset", "", 0}, - {"valid mid-range", "5698", 5698}, - {"valid low boundary", "1", 1}, - {"valid high boundary", "65535", 65535}, - {"non-numeric", "abc", 0}, - {"zero", "0", 0}, - {"negative", "-5", 0}, - {"above uint16", "65536", 0}, - {"way above uint16", "99999", 0}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Setenv("RADIANCE_PEER_EXTERNAL_PORT", tc.env) - assert.Equal(t, tc.want, manualPort()) - }) - } -} - -// manualPortForwarder must satisfy the portForwarder contract: MapPort -// returns a Mapping using the configured port for both internal and -// external (no rewrite — that's the user's responsibility), UnmapPort -// and StartRenewal are no-ops, and ExternalIP returns "" so the server -// substitutes the IP it observed on the request. -func TestManualPortForwarder(t *testing.T) { - f := &manualPortForwarder{port: 5698} - - m, err := f.MapPort(context.Background(), 30001, "ignored") - require.NoError(t, err) - assert.Equal(t, uint16(5698), m.ExternalPort) - assert.Equal(t, uint16(5698), m.InternalPort, "external==internal — user mapped them themselves") - assert.Equal(t, "manual-env", m.Method) - - require.NoError(t, f.UnmapPort(context.Background()), "UnmapPort is a no-op for manual forwarders") - - // StartRenewal must not panic or block. - f.StartRenewal(context.Background()) - - ip, err := f.ExternalIP(context.Background()) - require.NoError(t, err) - assert.Empty(t, ip, "empty ip signals server to use observed source address") -} - func TestAPIError_StringFormat(t *testing.T) { e := &APIError{Status: 422, Body: "could not connect to peer port"} assert.Contains(t, e.Error(), "422") diff --git a/portforward/manual.go b/portforward/manual.go new file mode 100644 index 00000000..a8535b27 --- /dev/null +++ b/portforward/manual.go @@ -0,0 +1,71 @@ +package portforward + +import ( + "context" + "fmt" + "strconv" +) + +// ManualForwarder satisfies the portForwarder contract without talking +// to a UPnP gateway. The user is expected to have configured a port +// forward on their router by hand (single-port 1:1 NAT — every consumer +// router exposes port forwarding as a single port number) and pointed +// peer.Client at it via setting or env var. +// +// Use case: networks where UPnP is disabled or unavailable (router has +// UPnP off for security, ISP-provided gateways without IGD, networks +// behind double-NAT). UPnP-based Forwarder fails in those environments. +type ManualForwarder struct { + port uint16 +} + +// NewManualForwarder builds a ManualForwarder for a pre-configured router +// port forward. port must be a valid TCP port; callers should obtain it +// from ParseManualPort (env-var path) or from a setting that already +// constrains the value to uint16. +func NewManualForwarder(port uint16) *ManualForwarder { + return &ManualForwarder{port: port} +} + +// ParseManualPort parses a string into a TCP port number. Values outside +// 1..65535 return an error so callers can log and fall through to UPnP +// discovery rather than register a non-listening port with the server. +func ParseManualPort(s string) (uint16, error) { + p, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("parse %q: %w", s, err) + } + if p < 1 || p > 65535 { + return 0, fmt.Errorf("port %d out of range (1..65535)", p) + } + return uint16(p), nil +} + +// MapPort reports the configured port as both external and internal. The +// router-side rule is already in place; nothing to do at the protocol +// layer. +func (m *ManualForwarder) MapPort(_ context.Context, _ uint16, _ string) (*Mapping, error) { + return &Mapping{ + ExternalPort: m.port, + InternalPort: m.port, + Method: "manual", + }, nil +} + +// UnmapPort is a no-op: the user owns the router rule and is responsible +// for removing it. +func (m *ManualForwarder) UnmapPort(_ context.Context) error { return nil } + +// StartRenewal is a no-op: manually-configured rules don't carry a UPnP +// lease and don't need refreshing. +func (m *ManualForwarder) StartRenewal(_ context.Context) {} + +// ExternalIP returns the empty string deliberately. With a manual port +// forward we have no UPnP gateway to ask for the WAN address, and +// probing a public IP service from the client adds a network roundtrip +// for information lantern-cloud already has — the server observes the +// peer's source address on the register call and uses that as the +// canonical external IP when this field is empty. +func (m *ManualForwarder) ExternalIP(_ context.Context) (string, error) { + return "", nil +} diff --git a/portforward/manual_test.go b/portforward/manual_test.go new file mode 100644 index 00000000..68c9cfed --- /dev/null +++ b/portforward/manual_test.go @@ -0,0 +1,66 @@ +package portforward + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ParseManualPort accepts 1..65535 verbatim and rejects everything else +// with an error so callers can log + fall through to UPnP discovery +// rather than register a non-listening port with lantern-cloud. +func TestParseManualPort(t *testing.T) { + tests := []struct { + name string + input string + want uint16 + wantErr bool + }{ + {"valid mid-range", "5698", 5698, false}, + {"valid low boundary", "1", 1, false}, + {"valid high boundary", "65535", 65535, false}, + {"empty", "", 0, true}, + {"non-numeric", "abc", 0, true}, + {"zero", "0", 0, true}, + {"negative", "-5", 0, true}, + {"above uint16", "65536", 0, true}, + {"way above uint16", "99999", 0, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseManualPort(tc.input) + if tc.wantErr { + assert.Error(t, err) + assert.Equal(t, uint16(0), got) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// ManualForwarder satisfies the portForwarder contract: MapPort returns +// a Mapping with external==internal port and the "manual" method tag, +// UnmapPort and StartRenewal are no-ops, ExternalIP returns "" so the +// server substitutes the IP it observed on the register call. +func TestManualForwarder(t *testing.T) { + f := NewManualForwarder(5698) + + m, err := f.MapPort(context.Background(), 30001, "ignored") + require.NoError(t, err) + assert.Equal(t, uint16(5698), m.ExternalPort) + assert.Equal(t, uint16(5698), m.InternalPort, "external==internal — user mapped them themselves") + assert.Equal(t, "manual", m.Method) + + require.NoError(t, f.UnmapPort(context.Background()), "UnmapPort is a no-op for manual forwarders") + + // StartRenewal must not panic or block. + f.StartRenewal(context.Background()) + + ip, err := f.ExternalIP(context.Background()) + require.NoError(t, err) + assert.Empty(t, ip, "empty IP signals server to use observed source address") +} From d460569e192a69c73853eca9fce87bd705eab304 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 14:40:13 -0600 Subject: [PATCH 39/63] peer/portforward/settings: address Copilot review on #500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four substantive findings; three additional Copilot comments review a pre-consolidation state of portforward/manual.go that the consolidation commit (22d1533) replaced wholesale — those are answered with the relevant context in the thread replies. 1. peer.Client.Start now range-checks the PeerManualPortKey setting before casting to uint16. A raw uint16 cast silently wraps negative values (-5 → 65531) and values above the port space (70000 → 4464), which would register a port the peer doesn't listen on (or, worse, one it does listen on for a different service). Out-of-range values are now logged at Warn and fall through to env-var / UPnP as if the setting were unset. 2. common/settings PeerManualPortKey doc now documents the 1..65535 valid range, behavior on out-of-range values, and the 0=unset contract. Dropped the peer.Client.Start / portforward.ManualForwarder code-location references — describes the contract generically. 3. portforward.NewManualForwarder doc tightened to state the caller- side validation contract (port must be 1..65535) without naming ParseManualPort or 'env-var path' / 'setting' as callers. No behavior change in #2 or #3; only #1 changes runtime behavior, and only for invalid setting values. Co-Authored-By: Claude Opus 4.7 --- common/settings/settings.go | 14 ++++++++------ peer/peer.go | 22 ++++++++++++++++++---- portforward/manual.go | 7 +++---- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/common/settings/settings.go b/common/settings/settings.go index 1c5e5cb1..29c68d37 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -57,12 +57,14 @@ const ( AutoConnectKey _key = "auto_connect" // bool PeerShareEnabledKey _key = "peer_share_enabled" // bool // PeerManualPortKey is the TCP port number the user has manually - // forwarded on their router (single-port 1:1 NAT). When non-zero, - // peer.Client.Start uses portforward.ManualForwarder with this port - // instead of probing UPnP. Surfaced as an Advanced setting in the - // Share My Connection UI for users on networks where UPnP is - // disabled or unavailable. - PeerManualPortKey _key = "peer_manual_port" // int (0 = use UPnP) + // forwarded on their router for the peer-proxy inbound (single- + // port 1:1 NAT). Valid range is 1..65535; 0 means unset, in which + // case the peer falls back to UPnP discovery. Out-of-range values + // (negative, > 65535) are logged on read and treated as unset + // rather than silently wrapping to a wrong port. Surfaced as an + // Advanced setting in the Share My Connection UI for users on + // networks where UPnP is disabled or unavailable. + PeerManualPortKey _key = "peer_manual_port" // int (0 = unset; 1..65535 = manual port) SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] diff --git a/peer/peer.go b/peer/peer.go index cdde6f7d..9efab16e 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -188,10 +188,24 @@ func NewClient(cfg Config) (*Client, error) { // 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / // power-user override) // 3. fall through to UPnP discovery - if port := uint16(settings.GetInt(settings.PeerManualPortKey)); port != 0 { - slog.Info("peer client using manual port forward", - "port", port, "source", "setting") - return portforward.NewManualForwarder(port), nil + // + // Range-check the setting before casting to uint16 — a raw + // uint16 cast silently wraps negative values (-5 → 65531) + // and values above the port space (70000 → 4464), which + // would register a port we don't listen on (or, worse, one + // we do listen on for a different service). Out-of-range + // values fall through to env-var and then UPnP as if the + // setting were unset. + if raw := settings.GetInt(settings.PeerManualPortKey); raw != 0 { + if raw < 1 || raw > 65535 { + slog.Warn("ignoring out-of-range peer_manual_port setting; falling through to env / UPnP", + "value", raw) + } else { + port := uint16(raw) + slog.Info("peer client using manual port forward", + "port", port, "source", "setting") + return portforward.NewManualForwarder(port), nil + } } if raw := env.GetString(env.PeerExternalPort); raw != "" { port, err := portforward.ParseManualPort(raw) diff --git a/portforward/manual.go b/portforward/manual.go index a8535b27..8050d7d5 100644 --- a/portforward/manual.go +++ b/portforward/manual.go @@ -19,10 +19,9 @@ type ManualForwarder struct { port uint16 } -// NewManualForwarder builds a ManualForwarder for a pre-configured router -// port forward. port must be a valid TCP port; callers should obtain it -// from ParseManualPort (env-var path) or from a setting that already -// constrains the value to uint16. +// NewManualForwarder builds a ManualForwarder for a pre-configured +// router port forward. port must be in 1..65535; the caller is +// responsible for validating its input before calling. func NewManualForwarder(port uint16) *ManualForwarder { return &ManualForwarder{port: port} } From d517ef8ca8c6e5d1b389eaaadae16347fd44f5bd Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 14:49:34 -0600 Subject: [PATCH 40/63] peer/portforward: address Copilot review on #500 (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc-lint follow-ups per AGENTS.md (no code-location refs in comments): 1. portforward.ManualForwarder doc dropped the 'satisfies the portForwarder contract' phrasing (portForwarder is the peer package's private interface; mentioning it crosses a package boundary) and the 'peer.Client at it via setting or env var' reference. The new wording describes the type in terms of this package's own exported API: 'exposes the same Map/Unmap/ StartRenewal/ExternalIP surface as Forwarder but does no UPnP work.' 2. peer.Client.Start's resolution-order comment now spells the persisted setting name in quotes ('peer_manual_port') rather than the Go identifier (settings.PeerManualPortKey). The persisted name is the stable contract — if the Go identifier ever moves or renames, the comment stays correct without needing to be updated. Same treatment for the env-var line, which already used the stable name string. No behavior change. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 5 ++++- portforward/manual.go | 13 +++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index 9efab16e..0acf465f 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -184,11 +184,14 @@ func NewClient(cfg Config) (*Client, error) { // case every consumer router exposes). // // Resolution order: - // 1. settings.PeerManualPortKey (Advanced UI) + // 1. "peer_manual_port" setting (Advanced UI) // 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / // power-user override) // 3. fall through to UPnP discovery // + // Persisted names are quoted so the comment stays accurate + // if Go identifiers move or rename. + // // Range-check the setting before casting to uint16 — a raw // uint16 cast silently wraps negative values (-5 → 65531) // and values above the port space (70000 → 4464), which diff --git a/portforward/manual.go b/portforward/manual.go index 8050d7d5..a54ac3e7 100644 --- a/portforward/manual.go +++ b/portforward/manual.go @@ -6,15 +6,16 @@ import ( "strconv" ) -// ManualForwarder satisfies the portForwarder contract without talking -// to a UPnP gateway. The user is expected to have configured a port -// forward on their router by hand (single-port 1:1 NAT — every consumer -// router exposes port forwarding as a single port number) and pointed -// peer.Client at it via setting or env var. +// ManualForwarder exposes the same Map/Unmap/StartRenewal/ExternalIP +// surface as Forwarder but does no UPnP work. The user is expected to +// have configured a port forward on their router by hand (single-port +// 1:1 NAT — every consumer router exposes port forwarding as a single +// port number) and supplied the port number out-of-band. // // Use case: networks where UPnP is disabled or unavailable (router has // UPnP off for security, ISP-provided gateways without IGD, networks -// behind double-NAT). UPnP-based Forwarder fails in those environments. +// behind double-NAT). The UPnP-based Forwarder fails in those +// environments; this type lets callers bypass discovery entirely. type ManualForwarder struct { port uint16 } From 7f8224da7eaea5b4e74f87da659004552bbdc668 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 15:16:40 -0600 Subject: [PATCH 41/63] peer/portforward: address Copilot review on #503 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings (each surfaced as a duplicate thread, so 6 total): 1. portforward.ManualForwarder doc claimed 'every consumer router exposes port forwarding as a single port number' — broad and inaccurate (many routers support distinct external/internal ports). Reworded as an implementation requirement: 'this implementation reports the same value for both the external and internal port — callers needing distinct ports should use the UPnP-based Forwarder.' 2. Same misleading claim was inline in peer.go's NewForwarder closure comment. Replaced with the same implementation-requirement framing. 3. The manual-port resolution order (setting → env → UPnP) and the out-of-range setting behavior had no test coverage. Extracted the resolution logic into pickManualForwarder() so it's directly testable without standing up a real UPnP probe. The default NewForwarder factory now calls pickManualForwarder() first; nil return means fall through to UPnP. New TestPickManualForwarder covers 10 cases: - setting takes precedence over env - setting-only / env-only / both-unset - setting out-of-range (positive + negative) → fallthrough - setting out-of-range + env valid → env wins - setting unset + env unparseable → fallthrough - low/high boundary values (1 and 65535) No behavior change — the extraction is line-for-line equivalent to the previous inline logic. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 95 ++++++++++++++++++++++--------------------- peer/peer_test.go | 50 +++++++++++++++++++++++ portforward/manual.go | 8 ++-- 3 files changed, 104 insertions(+), 49 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index 0acf465f..ad51b21d 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -174,52 +174,8 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { - // Manual port-forward override. Use case: networks where - // UPnP is disabled or unavailable (router has UPnP off for - // security, ISP-provided gateways without IGD, networks - // behind double-NAT) but the user has manually configured - // a port forward on their router. We trust the user's - // config — no UPnP roundtrip — and report the configured - // port as both the external and internal port (the 1:1 - // case every consumer router exposes). - // - // Resolution order: - // 1. "peer_manual_port" setting (Advanced UI) - // 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / - // power-user override) - // 3. fall through to UPnP discovery - // - // Persisted names are quoted so the comment stays accurate - // if Go identifiers move or rename. - // - // Range-check the setting before casting to uint16 — a raw - // uint16 cast silently wraps negative values (-5 → 65531) - // and values above the port space (70000 → 4464), which - // would register a port we don't listen on (or, worse, one - // we do listen on for a different service). Out-of-range - // values fall through to env-var and then UPnP as if the - // setting were unset. - if raw := settings.GetInt(settings.PeerManualPortKey); raw != 0 { - if raw < 1 || raw > 65535 { - slog.Warn("ignoring out-of-range peer_manual_port setting; falling through to env / UPnP", - "value", raw) - } else { - port := uint16(raw) - slog.Info("peer client using manual port forward", - "port", port, "source", "setting") - return portforward.NewManualForwarder(port), nil - } - } - if raw := env.GetString(env.PeerExternalPort); raw != "" { - port, err := portforward.ParseManualPort(raw) - if err != nil { - slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), - "value", raw, "error", err) - } else { - slog.Info("peer client using manual port forward", - "port", port, "source", env.PeerExternalPort.String()) - return portforward.NewManualForwarder(port), nil - } + if fwd := pickManualForwarder(); fwd != nil { + return fwd, nil } // Explicitly return a nil interface on error — `return // portforward.NewForwarder(ctx)` collapses the (*Forwarder, error) @@ -654,6 +610,53 @@ func ensurePeerOutboundsBypassVPN(options string) (string, error) { return string(out), nil } +// pickManualForwarder resolves the manual port override against the +// two configured sources and returns a ManualForwarder, or nil if +// neither source supplies a valid port. The default NewForwarder +// factory in NewClient calls this first; nil means "fall through to +// UPnP discovery." +// +// Resolution order: +// +// 1. "peer_manual_port" setting (Advanced UI) +// 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / power-user) +// 3. nil — caller falls through to UPnP +// +// Persisted names are quoted so the comment stays accurate if Go +// identifiers move or rename. +// +// The setting is range-checked before casting to uint16 — a raw cast +// silently wraps negative values (-5 → 65531) and values above the +// port space (70000 → 4464), which would register a port the peer +// doesn't listen on (or, worse, one it does listen on for another +// service). Out-of-range / unparseable values are logged at Warn and +// the resolution falls through to the next source as if unset. +func pickManualForwarder() portForwarder { + if raw := settings.GetInt(settings.PeerManualPortKey); raw != 0 { + if raw < 1 || raw > 65535 { + slog.Warn("ignoring out-of-range peer_manual_port setting; falling through to env / UPnP", + "value", raw) + } else { + port := uint16(raw) + slog.Info("peer client using manual port forward", + "port", port, "source", "setting") + return portforward.NewManualForwarder(port) + } + } + if raw := env.GetString(env.PeerExternalPort); raw != "" { + port, err := portforward.ParseManualPort(raw) + if err != nil { + slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), + "value", raw, "error", err) + } else { + slog.Info("peer client using manual port forward", + "port", port, "source", env.PeerExternalPort.String()) + return portforward.NewManualForwarder(port) + } + } + return nil +} + func pickInternalPort() uint16 { return uint16(internalPortMin + rand.IntN(internalPortMax-internalPortMin)) } diff --git a/peer/peer_test.go b/peer/peer_test.go index 162ab7ad..7d41b8ef 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/settings" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/portforward" ) @@ -627,6 +628,55 @@ func TestPickInternalPort_InRange(t *testing.T) { } } +// pickManualForwarder is the default-NewForwarder factory's first +// branch: setting → env-var → nil (= caller falls through to UPnP). +// Tests each resolution path and the out-of-range / unparseable +// fallthrough behavior. Out-of-range setting + unset env returns nil +// — peer.NewClient's caller treats that as "use UPnP discovery." +func TestPickManualForwarder(t *testing.T) { + tests := []struct { + name string + setting int // 0 means unset + envVar string // "" means unset + wantManual bool + wantPort uint16 + }{ + {"setting takes precedence over env", 5698, "1234", true, 5698}, + {"setting only", 5698, "", true, 5698}, + {"env only", 0, "5698", true, 5698}, + {"both unset → fall through", 0, "", false, 0}, + {"setting out of range, env unset → fall through", 70000, "", false, 0}, + {"setting negative, env unset → fall through", -5, "", false, 0}, + {"setting out of range, env valid → env wins", 70000, "5698", true, 5698}, + {"setting unset, env unparseable → fall through", 0, "abc", false, 0}, + {"setting valid low boundary", 1, "", true, 1}, + {"setting valid high boundary", 65535, "", true, 65535}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, settings.InitSettings(t.TempDir())) + t.Cleanup(settings.Reset) + if tc.setting != 0 { + require.NoError(t, settings.Set(settings.PeerManualPortKey, tc.setting)) + } + t.Setenv("RADIANCE_PEER_EXTERNAL_PORT", tc.envVar) + + fwd := pickManualForwarder() + if !tc.wantManual { + assert.Nil(t, fwd, "expected fall-through (nil) but got a forwarder") + return + } + require.NotNil(t, fwd, "expected manual forwarder, got nil") + // Verify the chosen port via the public MapPort surface — + // ManualForwarder.port is unexported, MapPort echoes it. + mapping, err := fwd.MapPort(context.Background(), 0, "") + require.NoError(t, err) + assert.Equal(t, tc.wantPort, mapping.ExternalPort) + assert.Equal(t, tc.wantPort, mapping.InternalPort) + }) + } +} + func TestAPIError_StringFormat(t *testing.T) { e := &APIError{Status: 422, Body: "could not connect to peer port"} assert.Contains(t, e.Error(), "422") diff --git a/portforward/manual.go b/portforward/manual.go index a54ac3e7..8997cc23 100644 --- a/portforward/manual.go +++ b/portforward/manual.go @@ -8,9 +8,11 @@ import ( // ManualForwarder exposes the same Map/Unmap/StartRenewal/ExternalIP // surface as Forwarder but does no UPnP work. The user is expected to -// have configured a port forward on their router by hand (single-port -// 1:1 NAT — every consumer router exposes port forwarding as a single -// port number) and supplied the port number out-of-band. +// have configured a port forward on their router by hand and supplied +// the port number out-of-band. This implementation reports the same +// value for both the external and internal port — callers needing +// distinct external/internal ports should use the UPnP-based Forwarder +// (which can negotiate them) or build their own portForwarder. // // Use case: networks where UPnP is disabled or unavailable (router has // UPnP off for security, ISP-provided gateways without IGD, networks From eb55d0fd29fb4c876813835092bf7c6ba023043a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 16:11:03 -0600 Subject: [PATCH 42/63] peer/portforward: address Copilot review on #503 (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit portforward/manual.go: - ManualForwarder.MapPort now sets Protocol="TCP" to match the UPnP-based Forwarder, which hard-codes the same value. Samizdat-in inbound traffic is TCP-only on both code paths; widening to UDP later means widening both forwarders together. - Defensive guard: MapPort returns an error when constructed with port==0. pickManualForwarder already range-checks 1..65535 before calling NewManualForwarder, but the check belongs on the type itself — a caller that bypasses the validator (programmatic use, tests, future code paths) gets a clear error instead of silently registering port 0 with lantern-cloud. portforward/manual_test.go: - Asserts Protocol="TCP" in TestManualForwarder. - TestManualForwarder_RejectsZeroPort verifies the new guard. peer/peer.go: - slog warning in the env-var path used 'error' as the attribute key while every other log line in this file uses 'err'. Renamed for log-aggregation consistency. Co-Authored-By: Claude Opus 4.7 --- peer/peer.go | 2 +- portforward/manual.go | 13 ++++++++++++- portforward/manual_test.go | 18 +++++++++++++++--- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index ad51b21d..71f82b90 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -647,7 +647,7 @@ func pickManualForwarder() portForwarder { port, err := portforward.ParseManualPort(raw) if err != nil { slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), - "value", raw, "error", err) + "value", raw, "err", err) } else { slog.Info("peer client using manual port forward", "port", port, "source", env.PeerExternalPort.String()) diff --git a/portforward/manual.go b/portforward/manual.go index 8997cc23..977c9393 100644 --- a/portforward/manual.go +++ b/portforward/manual.go @@ -45,11 +45,22 @@ func ParseManualPort(s string) (uint16, error) { // MapPort reports the configured port as both external and internal. The // router-side rule is already in place; nothing to do at the protocol -// layer. +// layer. Returns an error if the forwarder was constructed with port==0 +// (not a valid listen/registration port) so callers can fall through to +// UPnP rather than register a port no peer will listen on. +// +// Protocol is set to "TCP" to match the UPnP-based Forwarder, which +// hard-codes the same value. Samizdat-in inbound traffic is TCP-only; +// when UDP support is added the two forwarders should be widened +// together. func (m *ManualForwarder) MapPort(_ context.Context, _ uint16, _ string) (*Mapping, error) { + if m.port == 0 { + return nil, fmt.Errorf("manual forwarder constructed with port=0") + } return &Mapping{ ExternalPort: m.port, InternalPort: m.port, + Protocol: "TCP", Method: "manual", }, nil } diff --git a/portforward/manual_test.go b/portforward/manual_test.go index 68c9cfed..49d01253 100644 --- a/portforward/manual_test.go +++ b/portforward/manual_test.go @@ -43,9 +43,10 @@ func TestParseManualPort(t *testing.T) { } // ManualForwarder satisfies the portForwarder contract: MapPort returns -// a Mapping with external==internal port and the "manual" method tag, -// UnmapPort and StartRenewal are no-ops, ExternalIP returns "" so the -// server substitutes the IP it observed on the register call. +// a Mapping with external==internal port, Protocol="TCP" matching the +// UPnP forwarder, and the "manual" method tag. UnmapPort and +// StartRenewal are no-ops, ExternalIP returns "" so the server +// substitutes the IP it observed on the register call. func TestManualForwarder(t *testing.T) { f := NewManualForwarder(5698) @@ -53,6 +54,7 @@ func TestManualForwarder(t *testing.T) { require.NoError(t, err) assert.Equal(t, uint16(5698), m.ExternalPort) assert.Equal(t, uint16(5698), m.InternalPort, "external==internal — user mapped them themselves") + assert.Equal(t, "TCP", m.Protocol, "Protocol matches UPnP forwarder's hard-coded value") assert.Equal(t, "manual", m.Method) require.NoError(t, f.UnmapPort(context.Background()), "UnmapPort is a no-op for manual forwarders") @@ -64,3 +66,13 @@ func TestManualForwarder(t *testing.T) { require.NoError(t, err) assert.Empty(t, ip, "empty IP signals server to use observed source address") } + +// MapPort defensively rejects a zero-port forwarder so a caller that +// somehow gets one (bypassing pickManualForwarder's range check) can +// fall through to UPnP rather than register a non-listening port. +func TestManualForwarder_RejectsZeroPort(t *testing.T) { + f := NewManualForwarder(0) + m, err := f.MapPort(context.Background(), 30001, "ignored") + assert.Nil(t, m) + assert.Error(t, err) +} From 79400efec30e90a373de9b300461f11841ce72b9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 31 May 2026 16:13:44 -0600 Subject: [PATCH 43/63] portforward: add ProbeUPnP for the pre-flight 'would SmC work here?' check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Share My Connection UI flow needs to decide which mode to start (Full SmC vs Unbounded) based on whether UPnP discovery succeeds on the user's network. Previously that gate didn't exist in the lib — share_my_connection.dart was using Random().nextBool() as a stand-in, which routed half of opted-in users to a mode that didn't match their actual capabilities. ProbeUPnP wraps NewForwarder and returns true on success, false on any failure (ErrNoPortForwarding, ctx timeout, ctx cancellation). The discovered forwarder is discarded after the probe; Forwarder holds no goroutines or sockets that need explicit cleanup, so a subsequent NewForwarder call can re-discover without coordination. Callers (the lantern-core FFI export + the Dart-side isPeerProxyEnabled-style call) treat true/false binary; the underlying error is not surfaced because no UI flow does anything productive with the distinction between 'no IGD on this LAN' and 'discovery timed out'. TestProbeUPnP_CancelledContextReturnsFalse pins the cancellation-fast-path contract: a cancelled ctx must yield false within ~2s, not block for the M-SEARCH multicast wait. A positive- path test would require a real IGD on the CI host's network, which isn't available. Co-Authored-By: Claude Opus 4.7 --- portforward/portforward.go | 22 ++++++++++++++++++++++ portforward/portforward_test.go | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/portforward/portforward.go b/portforward/portforward.go index 576f7e38..3ae2ff93 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -50,6 +50,28 @@ type Forwarder struct { cancel context.CancelFunc } +// ProbeUPnP reports whether IGD discovery on the local network turns up a +// gateway that could host a port mapping. No port is actually mapped — this +// is the "would Share My Connection's residential-proxy mode work on this +// network?" pre-flight check that UI flows show before committing to the +// long-lived SmC path. +// +// True means discovery succeeded; false means either no gateway was found +// (ErrNoPortForwarding) or ctx expired before discovery completed. Callers +// generally treat both as "not available" — distinguishing them requires +// returning the underlying error, which most UI surfaces don't have a +// productive use for. +// +// Pick a 5-10s timeout on ctx: M-SEARCH multicast waits for replies and a +// fast bail leaves slower gateways unmatched. The discovered forwarder is +// discarded after the probe; it holds no goroutines or sockets that need +// explicit cleanup, so a subsequent NewForwarder call can re-discover +// without coordination. +func ProbeUPnP(ctx context.Context) bool { + _, err := NewForwarder(ctx) + return err == nil +} + // NewForwarder discovers the local gateway and returns a Forwarder bound to // it. Callers should pick a 5-10s timeout on ctx — UPnP discovery is M-SEARCH // multicast and waits for replies. diff --git a/portforward/portforward_test.go b/portforward/portforward_test.go index 7d6e0ee2..c52982b5 100644 --- a/portforward/portforward_test.go +++ b/portforward/portforward_test.go @@ -107,6 +107,24 @@ func TestForwarder_MapPort_PropagatesGatewayError(t *testing.T) { assert.ErrorContains(t, err, "add port mapping") } +// ProbeUPnP wraps NewForwarder and returns false on any error, including +// ctx cancellation / deadline expiration. A successful probe requires a +// real IGD on the test host's network, which CI doesn't have — but the +// negative-path contract (cancelled ctx → false within the cancel +// window, no leaked goroutines) is what callers actually depend on for +// timely UI feedback when UPnP is unavailable. +func TestProbeUPnP_CancelledContextReturnsFalse(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + got := ProbeUPnP(ctx) + elapsed := time.Since(start) + + assert.False(t, got, "cancelled ctx must yield false") + assert.Less(t, elapsed, 2*time.Second, "probe should bail fast on a cancelled ctx, not wait for M-SEARCH") +} + // MapPort must respect the caller's context — a hung router shouldn't tie up // Start past its deadline. func TestForwarder_MapPort_RespectsContextCancellation(t *testing.T) { From ed6a40c07b030ad25d0fec74a7da30055257ae87 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 18:48:45 -0600 Subject: [PATCH 44/63] unbounded: integrate broflake widget-proxy lifecycle manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the radiance side of the Unbounded ("Basic mode" in the SmC UI) WebRTC donor-mode integration. Self-contained package under radiance/unbounded; drops cleanly onto the current branch without needing the larger vpn/ refactor that adam/unbounded-widget-proxy ships with. unbounded.SetEnabled(bool) toggles the local opt-in (settings.UnboundedKey). InitSubscription wires the manager to config.NewConfigEvent — the broflake widget actually runs only when: 1. settings.UnboundedKey is true (local opt-in) 2. server cfg.Features[UNBOUNDED] is on 3. server provides cfg.Unbounded discovery + egress URLs Each consumer connection change emits unbounded.ConnectionEvent on the radiance event bus, mirroring the shape of peer.ConnectionEvent so lantern-core subscribers can feed both into one Flutter event stream. Wired into LocalBackend.Start so the manager is live for the process lifetime; sync.Once-guarded against double-subscribe. Mostly a port of the unbounded.go file from adam/unbounded-widget-proxy (getlantern/radiance#336), with the package moved out of vpn/ since that branch's vpn/ is undergoing a separate refactor and we want the unbounded code to land independently. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/radiance.go | 8 ++ common/settings/settings.go | 8 ++ unbounded/unbounded.go | 245 ++++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 unbounded/unbounded.go diff --git a/backend/radiance.go b/backend/radiance.go index f90476e2..2761fa9f 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -38,6 +38,7 @@ import ( "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/telemetry" "github.com/getlantern/radiance/traces" + "github.com/getlantern/radiance/unbounded" "github.com/getlantern/radiance/vpn" "github.com/sagernet/sing-box/adapter" @@ -232,6 +233,13 @@ func (r *LocalBackend) Start() { r.resumePeerShareIfEnabled() + // Wire the broflake / Unbounded widget proxy lifecycle to config + // updates. This single subscription handles all three start/stop + // triggers (local toggle, server feature flag, server-supplied + // config); InitSubscription is sync.Once-guarded so a future Start + // retry after Close won't double-subscribe. + unbounded.InitSubscription() + // set country code in settings when new config is received so it can be included in issue reports events.SubscribeOnce(func(evt config.NewConfigEvent) { if env.GetString(env.Country) != "" { diff --git a/common/settings/settings.go b/common/settings/settings.go index 29c68d37..55743b03 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -65,6 +65,14 @@ const ( // Advanced setting in the Share My Connection UI for users on // networks where UPnP is disabled or unavailable. PeerManualPortKey _key = "peer_manual_port" // int (0 = unset; 1..65535 = manual port) + // UnboundedKey is the local opt-in for the broflake / Unbounded + // widget proxy. When true AND the server-side Features[unbounded] + // flag is on AND the server provides UnboundedConfig (discovery + // + egress URLs), the widget proxy starts. Surfaced as a "Basic + // mode" option in the Share My Connection UI for networks where + // UPnP isn't workable but the user still wants to contribute via + // the WebRTC-based donor path. + UnboundedKey _key = "unbounded" // bool SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go new file mode 100644 index 00000000..aee88841 --- /dev/null +++ b/unbounded/unbounded.go @@ -0,0 +1,245 @@ +// Package unbounded manages the broflake / Unbounded widget-proxy lifecycle. +// +// Unbounded is the WebRTC-based donor mode for Lantern's Share My Connection +// feature: the local user contributes bandwidth to censored users via short- +// lived WebRTC sessions brokered through a discovery server, without exposing +// a long-lived inbound port the way the samizdat-over-UPnP "Share My +// Connection" mode does. It's the lower-bandwidth, lower-risk, universally- +// applicable alternative to SmC — works on networks where UPnP is disabled +// or unavailable, and the peer's residential IP isn't tied to a single +// long-lived inbound listener. +// +// Three conditions must all hold for the widget proxy to actually run: +// +// 1. settings.UnboundedKey is true (local opt-in via the UI toggle) +// 2. server-side cfg.Features[UNBOUNDED] is enabled (server says go) +// 3. server-side cfg.Unbounded provides discovery + egress URLs +// +// The manager subscribes to config.NewConfigEvent and recomputes the +// running state on every config update; it also re-evaluates when +// SetEnabled flips the local toggle. Each consumer connection change +// (accept / disconnect) emits a ConnectionEvent on the radiance event +// bus so the same Flutter globe used for SmC can render arcs without +// caring which protocol produced them. +package unbounded + +import ( + "context" + "log/slog" + "net" + "sync" + + C "github.com/getlantern/common" + + "github.com/getlantern/broflake/clientcore" + + "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/config" + "github.com/getlantern/radiance/events" +) + +// ConnectionEvent fires every time a consumer (i.e. a censored client +// being routed through this widget proxy) connects or disconnects via +// the broflake mesh. State: +1 on accept, -1 on close. WorkerIdx is +// broflake's internal worker slot identifier — used by the Flutter +// globe to pair connect/disconnect events for the same arc. Addr is +// the remote consumer's IP if broflake exposes it, otherwise empty. +// +// Shape mirrors radiance/peer.ConnectionEvent so consumers (lantern- +// core's listenPeerConnectionEvents in particular) can subscribe with +// a single discriminator and feed both the SmC and Unbounded streams +// into the same globe view. +type ConnectionEvent struct { + events.Event + State int `json:"state"` + WorkerIdx int `json:"workerIdx"` + Addr string `json:"addr"` +} + +var manager = &unboundedManager{} + +type unboundedManager struct { + mu sync.Mutex + cancel context.CancelFunc + lastCfg *C.UnboundedConfig // most recent server-supplied config +} + +// Enabled reports whether the local opt-in is set. Doesn't say whether +// the proxy is currently running (server flag and config can override). +func Enabled() bool { + return settings.GetBool(settings.UnboundedKey) +} + +// SetEnabled flips the local opt-in. When enabling, the proxy starts +// immediately if a server config is already cached; otherwise it +// starts on the next config event. When disabling, the proxy stops. +// Idempotent — calling with the current value is a no-op. +func SetEnabled(enable bool) error { + if Enabled() == enable { + return nil + } + if err := settings.Set(settings.UnboundedKey, enable); err != nil { + return err + } + slog.Info("Unbounded widget proxy local opt-in changed", "enabled", enable) + if enable { + manager.mu.Lock() + cfg := manager.lastCfg + manager.mu.Unlock() + if cfg != nil { + manager.start(cfg) + } else { + slog.Info("Unbounded: enabled locally, will start when server config arrives") + } + } else { + manager.stop() + } + return nil +} + +// InitSubscription wires the manager into radiance's config event bus. +// Called once at LocalBackend startup; the subscription lives for the +// process lifetime, so repeated calls would leak goroutines — hence +// the package-level guard. +func InitSubscription() { + initOnce.Do(func() { + events.Subscribe(func(evt config.NewConfigEvent) { + if evt.New == nil { + return + } + // config.Config is a type alias for C.ConfigResponse on + // the current radiance branch — no nested .ConfigResponse + // field, just dereference and use directly. + cfg := *evt.New + manager.mu.Lock() + manager.lastCfg = cfg.Unbounded + running := manager.cancel != nil + manager.mu.Unlock() + + shouldRun := shouldRunUnbounded(cfg) + switch { + case shouldRun && !running: + manager.start(cfg.Unbounded) + case !shouldRun && running: + manager.stop() + } + }) + }) +} + +var initOnce sync.Once + +// Stop tears down a running widget proxy. Idempotent. Used as a +// LocalBackend shutdown hook so the broflake goroutines don't outlive +// the radiance process during a graceful exit. +func Stop(_ context.Context) error { + manager.stop() + return nil +} + +func shouldRunUnbounded(cfg C.ConfigResponse) bool { + if !settings.GetBool(settings.UnboundedKey) { + return false + } + if !cfg.Features[C.UNBOUNDED] { + return false + } + if cfg.Unbounded == nil { + return false + } + return true +} + +func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { + m.mu.Lock() + defer m.mu.Unlock() + if m.cancel != nil { + return // already running + } + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + go func() { + slog.Info("Unbounded: starting broflake widget proxy") + + bfOpt := clientcore.NewDefaultBroflakeOptions() + bfOpt.ClientType = "widget" + if ucfg != nil { + if ucfg.CTableSize > 0 { + bfOpt.CTableSize = ucfg.CTableSize + } + if ucfg.PTableSize > 0 { + bfOpt.PTableSize = ucfg.PTableSize + } + } + + // Wire the broflake connection callback into the radiance event + // bus so the Flutter globe (and any future abuse aggregation) + // sees consumer connect/disconnect. + bfOpt.OnConnectionChangeFunc = func(state int, workerIdx int, addr net.IP) { + addrStr := "" + if addr != nil { + addrStr = addr.String() + } + slog.Debug("Unbounded: consumer connection change", + "state", state, "workerIdx", workerIdx, "addr", addrStr) + events.Emit(ConnectionEvent{ + State: state, + WorkerIdx: workerIdx, + Addr: addrStr, + }) + } + + rtcOpt := clientcore.NewDefaultWebRTCOptions() + if ucfg != nil { + if ucfg.DiscoverySrv != "" { + rtcOpt.DiscoverySrv = ucfg.DiscoverySrv + } + if ucfg.DiscoveryEndpoint != "" { + rtcOpt.Endpoint = ucfg.DiscoveryEndpoint + } + } + + egOpt := clientcore.NewDefaultEgressOptions() + if ucfg != nil { + if ucfg.EgressAddr != "" { + egOpt.Addr = ucfg.EgressAddr + } + if ucfg.EgressEndpoint != "" { + egOpt.Endpoint = ucfg.EgressEndpoint + } + } + + // BroflakeConn is for clients routing traffic *through* the + // mesh. A widget proxy only donates bandwidth, so the conn + // is unused — discard it. + _, ui, err := clientcore.NewBroflake(bfOpt, rtcOpt, egOpt) + if err != nil { + slog.Error("Unbounded: failed to create broflake widget", "error", err) + cancel() + m.mu.Lock() + m.cancel = nil + m.mu.Unlock() + return + } + + slog.Info("Unbounded: broflake widget proxy started") + <-ctx.Done() + slog.Info("Unbounded: stopping broflake widget proxy") + ui.Stop() + m.mu.Lock() + m.cancel = nil + m.mu.Unlock() + slog.Info("Unbounded: broflake widget proxy stopped") + }() +} + +func (m *unboundedManager) stop() { + m.mu.Lock() + defer m.mu.Unlock() + if m.cancel != nil { + m.cancel() + m.cancel = nil + } +} From 844e98ffec05c0dcbc19cf741f5d0836fc2d6fc2 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 14:56:49 -0600 Subject: [PATCH 45/63] unbounded/backend: address Copilot review on #501 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four substantive findings + one stale comment (settings.go vpn.Init… reference) answered via reply. 1. PatchSettings now dispatches settings.UnboundedKey to unbounded.SetEnabled the same way it dispatches PeerShareEnabledKey to applyPeerShare. The UI toggle now starts/stops the widget proxy immediately rather than persisting the value and waiting for the next NewConfigEvent. 2. Close now invokes unbounded.Stop(...) before r.cancel(). unbounded workers run on a context.Background-derived ctx (they must outlive any single NewConfigEvent), so without an explicit shutdown hook the broflake widget goroutine survived backend close. Uses a fresh 5s-bounded ctx so a cancelled shutdown path doesn't skip the Stop. 3. SetEnabled now respects the full three-condition predicate before starting. Previously it called manager.start whenever cached *UnboundedConfig was non-nil, ignoring the cached Features[UNBOUNDED] flag — meaning a local toggle could start the proxy even when the server had said don't. Added lastFeatureOn to unboundedManager (set alongside lastCfg on every NewConfigEvent) and a shouldStart() method that re-checks all three conditions. SetEnabled now uses shouldStart(); the standalone shouldRunUnbounded function is removed (its job is now done by the method). 4. unbounded.ConnectionEvent shape aligned with peer.ConnectionEvent: {State, Source, Timestamp}. WorkerIdx is dropped from the wire shape (it remains in the per-callback Debug log for diagnostics but isn't part of the event contract). Doc comment updated to spell out the field semantics. Consumers that need to pair accept/close events for the same arc now key off Source (or arrival sequence within a single connection's lifetime, which is what the globe currently does). The fifth Copilot comment cited a 'vpn.InitUnboundedSubscription' reference in common/settings/settings.go:75 that no longer exists — the consolidation cascade rewrote that doc to say 'the widget proxy starts' without naming the lifecycle function. Answered in the thread rather than re-edited. Co-Authored-By: Claude Opus 4.7 --- backend/radiance.go | 21 +++++++++ unbounded/unbounded.go | 102 +++++++++++++++++++++++------------------ 2 files changed, 79 insertions(+), 44 deletions(-) diff --git a/backend/radiance.go b/backend/radiance.go index 2761fa9f..7f01b642 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -326,6 +326,17 @@ func (r *LocalBackend) Close() { r.closeOnce.Do(func() { slog.Debug("Closing Radiance") r.closePeerClient() + // unbounded.start spawns its worker on a context.Background- + // derived ctx (it has to outlive any single NewConfigEvent), + // so Close has to explicitly tell it to shut down — otherwise + // the broflake widget goroutine survives backend close and + // leaks until process exit. Use a fresh ctx so a cancelled + // shutdown path doesn't skip the Stop. + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := unbounded.Stop(stopCtx); err != nil { + slog.Warn("unbounded stop on backend close returned error", "error", err) + } + cancel() // vpnClient is always set in production via NewLocalBackend, but // peer-focused unit tests construct partial LocalBackends without // one. Guard the call so Close stays robust under those paths @@ -510,6 +521,16 @@ func (r *LocalBackend) PatchSettings(updates settings.Settings) error { } } + // Drive the Unbounded widget proxy off the toggle change immediately + // rather than waiting for the next NewConfigEvent to re-evaluate. + // SetEnabled is internally idempotent and checks the cached server + // feature flag + config before actually starting the worker. + if _, ok := diff[settings.UnboundedKey]; ok { + if err := unbounded.SetEnabled(settings.GetBool(settings.UnboundedKey)); err != nil { + slog.Warn("unbounded toggle failed", "error", err) + } + } + return nil } diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index aee88841..ad9eb622 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -28,6 +28,7 @@ import ( "log/slog" "net" "sync" + "time" C "github.com/getlantern/common" @@ -40,28 +41,44 @@ import ( // ConnectionEvent fires every time a consumer (i.e. a censored client // being routed through this widget proxy) connects or disconnects via -// the broflake mesh. State: +1 on accept, -1 on close. WorkerIdx is -// broflake's internal worker slot identifier — used by the Flutter -// globe to pair connect/disconnect events for the same arc. Addr is -// the remote consumer's IP if broflake exposes it, otherwise empty. +// the broflake mesh. // -// Shape mirrors radiance/peer.ConnectionEvent so consumers (lantern- -// core's listenPeerConnectionEvents in particular) can subscribe with -// a single discriminator and feed both the SmC and Unbounded streams -// into the same globe view. +// State +1 on accept, -1 on close +// Source consumer's IP if broflake exposes it, otherwise empty +// Timestamp emit time in Unix milliseconds +// +// Shape is identical to radiance/peer.ConnectionEvent so a single +// subscriber can handle both the SmC-via-samizdat stream and the +// Unbounded-via-broflake stream as one. Broflake's internal worker- +// slot identifier is not surfaced — a consumer that needs to pair +// accept/close events for the same arc keys off Source (or the +// event's arrival sequence within a single connection lifetime, +// which is what the globe currently does). type ConnectionEvent struct { events.Event State int `json:"state"` - WorkerIdx int `json:"workerIdx"` - Addr string `json:"addr"` + Source string `json:"source"` + Timestamp int64 `json:"timestamp"` } var manager = &unboundedManager{} type unboundedManager struct { - mu sync.Mutex - cancel context.CancelFunc - lastCfg *C.UnboundedConfig // most recent server-supplied config + mu sync.Mutex + cancel context.CancelFunc + // lastCfg + lastFeatureOn cache the server-side half of the + // three-condition predicate so SetEnabled can re-evaluate + // immediately when the local toggle flips, without waiting for + // the next NewConfigEvent. Both are updated atomically when a + // new config arrives. + lastCfg *C.UnboundedConfig + lastFeatureOn bool +} + +// shouldStart reports whether all three start conditions hold. Caller +// must hold m.mu. +func (m *unboundedManager) shouldStart() bool { + return settings.GetBool(settings.UnboundedKey) && m.lastFeatureOn && m.lastCfg != nil } // Enabled reports whether the local opt-in is set. Doesn't say whether @@ -71,9 +88,10 @@ func Enabled() bool { } // SetEnabled flips the local opt-in. When enabling, the proxy starts -// immediately if a server config is already cached; otherwise it -// starts on the next config event. When disabling, the proxy stops. -// Idempotent — calling with the current value is a no-op. +// immediately if all three start conditions hold (local toggle + server +// feature flag + server config cached); otherwise it stays stopped and +// the next NewConfigEvent will reevaluate. When disabling, the proxy +// stops. Idempotent — calling with the current value is a no-op. func SetEnabled(enable bool) error { if Enabled() == enable { return nil @@ -82,17 +100,24 @@ func SetEnabled(enable bool) error { return err } slog.Info("Unbounded widget proxy local opt-in changed", "enabled", enable) - if enable { - manager.mu.Lock() - cfg := manager.lastCfg - manager.mu.Unlock() - if cfg != nil { - manager.start(cfg) - } else { - slog.Info("Unbounded: enabled locally, will start when server config arrives") - } - } else { + if !enable { manager.stop() + return nil + } + manager.mu.Lock() + shouldStart := manager.shouldStart() + cfg := manager.lastCfg + feature := manager.lastFeatureOn + manager.mu.Unlock() + if shouldStart { + manager.start(cfg) + return nil + } + switch { + case cfg == nil: + slog.Info("Unbounded: enabled locally, waiting for server config") + case !feature: + slog.Info("Unbounded: enabled locally, but server feature flag is off") } return nil } @@ -113,13 +138,15 @@ func InitSubscription() { cfg := *evt.New manager.mu.Lock() manager.lastCfg = cfg.Unbounded + manager.lastFeatureOn = cfg.Features[C.UNBOUNDED] + shouldRun := manager.shouldStart() running := manager.cancel != nil + ucfg := manager.lastCfg manager.mu.Unlock() - shouldRun := shouldRunUnbounded(cfg) switch { case shouldRun && !running: - manager.start(cfg.Unbounded) + manager.start(ucfg) case !shouldRun && running: manager.stop() } @@ -137,19 +164,6 @@ func Stop(_ context.Context) error { return nil } -func shouldRunUnbounded(cfg C.ConfigResponse) bool { - if !settings.GetBool(settings.UnboundedKey) { - return false - } - if !cfg.Features[C.UNBOUNDED] { - return false - } - if cfg.Unbounded == nil { - return false - } - return true -} - func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { m.mu.Lock() defer m.mu.Unlock() @@ -183,11 +197,11 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { addrStr = addr.String() } slog.Debug("Unbounded: consumer connection change", - "state", state, "workerIdx", workerIdx, "addr", addrStr) + "state", state, "workerIdx", workerIdx, "source", addrStr) events.Emit(ConnectionEvent{ State: state, - WorkerIdx: workerIdx, - Addr: addrStr, + Source: addrStr, + Timestamp: time.Now().UnixMilli(), }) } From b6721414a2201e71e95fe502eb58adcc76399a8a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 15:22:25 -0600 Subject: [PATCH 46/63] unbounded/ipc/backend: address Copilot review on #501 (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four substantive findings on the cross-process / lifecycle surface: 1. PatchSettings's call to SetEnabled was a no-op for UI toggles because settings.Patch above already persisted UnboundedKey, and SetEnabled's early-return (`Enabled() == enable`) then short- circuited before evaluating the manager. Split SetEnabled into two paths: SetEnabled (persist if changed + Apply) for direct callers, and Apply() for callers that have already persisted. PatchSettings now calls Apply() directly. Apply also has a guard so a second 'enable=true' call when already running is a no-op (the manager.start path is also guarded but the manager-running check is cheaper). 2. unbounded.ConnectionEvent had no IPC bridge — events.Subscribe is process-local, so cross-process consumers (Flutter via Liblantern) could not see them despite the JSON shape matching peer.ConnectionEvent. Added /unbounded/connection/events SSE endpoint mirroring /peer/connection/events. Two separate streams client-side because events.Emit dispatches by concrete Go type; the Flutter side multiplexes them into one globe feed. 3. unbounded.Stop ignored its ctx and returned as soon as the cancel signal was queued, so LocalBackend.Close could proceed while the broflake goroutine was still inside NewBroflake or ui.Stop. Added a 'done' channel that the worker closes on exit; Stop now selects on done OR ctx.Done(). The 5s timeout LocalBackend.Close passes now actually bounds the wait. 4. ConnectionEvent doc claimed 'single subscriber can handle both streams' — true at the JSON level but not at the Go-type level (events.Subscribe is type-keyed; Subscribe[peer.ConnectionEvent] does not receive unbounded.ConnectionEvent). Reworded to be honest about the in-process distinction and to point at the IPC bridge as the fan-in point for cross-process consumers. Co-Authored-By: Claude Opus 4.7 --- backend/radiance.go | 11 +++-- ipc/server.go | 46 ++++++++++++++++++ unbounded/unbounded.go | 104 +++++++++++++++++++++++++++++++---------- 3 files changed, 132 insertions(+), 29 deletions(-) diff --git a/backend/radiance.go b/backend/radiance.go index 7f01b642..b657f402 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -523,11 +523,14 @@ func (r *LocalBackend) PatchSettings(updates settings.Settings) error { // Drive the Unbounded widget proxy off the toggle change immediately // rather than waiting for the next NewConfigEvent to re-evaluate. - // SetEnabled is internally idempotent and checks the cached server - // feature flag + config before actually starting the worker. + // settings.Patch above has already persisted the new value, so go + // straight to Apply() — SetEnabled would short-circuit on the + // already-matching persisted value and never re-evaluate the + // manager. Apply re-checks the three-condition predicate against + // the cached server-side state and starts or stops accordingly. if _, ok := diff[settings.UnboundedKey]; ok { - if err := unbounded.SetEnabled(settings.GetBool(settings.UnboundedKey)); err != nil { - slog.Warn("unbounded toggle failed", "error", err) + if err := unbounded.Apply(); err != nil { + slog.Warn("unbounded apply failed", "error", err) } } diff --git a/ipc/server.go b/ipc/server.go index 6825e7b8..6d9cca19 100644 --- a/ipc/server.go +++ b/ipc/server.go @@ -23,6 +23,7 @@ import ( "github.com/getlantern/radiance/events" rlog "github.com/getlantern/radiance/log" "github.com/getlantern/radiance/peer" + "github.com/getlantern/radiance/unbounded" "github.com/getlantern/radiance/vpn" sjson "github.com/sagernet/sing/common/json" @@ -69,6 +70,11 @@ const ( peerStatusEventsEndpoint = "/peer/status/events" peerConnectionEventsEndpoint = "/peer/connection/events" + // Unbounded ("Basic mode") endpoints. Connection events have the same + // JSON shape as /peer/connection/events but are emitted from a + // different in-process event type, so they need their own SSE bridge. + unboundedConnectionEventsEndpoint = "/unbounded/connection/events" + // Split tunnel endpoint splitTunnelEndpoint = "/split-tunnel" @@ -236,6 +242,7 @@ func newLocalAPI(b *backend.LocalBackend, withAuth bool) *localapi { // SSE skips the tracer middleware since it buffers the entire response body. mux.HandleFunc("GET "+peerStatusEventsEndpoint, s.peerStatusEventsHandler) mux.HandleFunc("GET "+peerConnectionEventsEndpoint, s.peerConnectionEventsHandler) + mux.HandleFunc("GET "+unboundedConnectionEventsEndpoint, s.unboundedConnectionEventsHandler) // Split tunnel mux.HandleFunc(splitTunnelEndpoint, traced(s.splitTunnelHandler)) @@ -567,6 +574,45 @@ func (s *localapi) peerConnectionEventsHandler(w http.ResponseWriter, r *http.Re } } +// unboundedConnectionEventsHandler streams unbounded.ConnectionEvent over SSE +// for each consumer accept/disconnect on the broflake widget proxy. Mirrors +// peerConnectionEventsHandler — the JSON shape is identical, but events.Emit +// dispatches by concrete Go type so the two streams need separate +// subscriptions. Cross-process consumers (Flutter via Liblantern) merge the +// two SSE endpoints client-side into one peer-connection feed for the globe. +func (s *localapi) unboundedConnectionEventsHandler(w http.ResponseWriter, r *http.Request) { + flusher := sseWriter(w) + if flusher == nil { + return + } + queue := make(chan unbounded.ConnectionEvent, 64) + sub := events.Subscribe(func(evt unbounded.ConnectionEvent) { + select { + case queue <- evt: + default: + // queue full — drop. Same rationale as the peer handler: + // better to lose this event than back up events.Emit. + } + }) + defer sub.Unsubscribe() + + for { + select { + case evt := <-queue: + data, err := json.Marshal(evt) + if err != nil { + continue + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return + } + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + /////////////////////// // Server selection // /////////////////////// diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index ad9eb622..465a834c 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -47,13 +47,18 @@ import ( // Source consumer's IP if broflake exposes it, otherwise empty // Timestamp emit time in Unix milliseconds // -// Shape is identical to radiance/peer.ConnectionEvent so a single -// subscriber can handle both the SmC-via-samizdat stream and the -// Unbounded-via-broflake stream as one. Broflake's internal worker- -// slot identifier is not surfaced — a consumer that needs to pair -// accept/close events for the same arc keys off Source (or the -// event's arrival sequence within a single connection lifetime, -// which is what the globe currently does). +// JSON shape is identical to radiance/peer.ConnectionEvent so a +// consumer reading both streams over IPC SSE can deserialize each +// frame with the same struct. In-process subscribers, however, +// receive these events on a separate channel — events.Subscribe is +// keyed by concrete Go type, so Subscribe[peer.ConnectionEvent] and +// Subscribe[unbounded.ConnectionEvent] are independent. The IPC +// bridge in ipc/server.go fans the two SSE endpoints into the +// unified peer-connection-events stream that the Flutter globe +// consumes. Broflake's internal worker-slot identifier is not +// surfaced — a consumer that needs to pair accept/close events for +// the same arc keys off Source (or arrival sequence within a single +// connection lifetime). type ConnectionEvent struct { events.Event State int `json:"state"` @@ -66,6 +71,13 @@ var manager = &unboundedManager{} type unboundedManager struct { mu sync.Mutex cancel context.CancelFunc + // done is closed by the worker goroutine when it actually exits + // (after NewBroflake returns and ui.Stop runs). Stop callers wait + // on this so backend shutdown blocks until the broflake widget is + // actually torn down — without it, Stop returns as soon as the + // cancel signal is queued and the rest of LocalBackend.Close can + // race the still-running worker. Nil when nothing is running. + done chan struct{} // lastCfg + lastFeatureOn cache the server-side half of the // three-condition predicate so SetEnabled can re-evaluate // immediately when the local toggle flips, without waiting for @@ -87,20 +99,35 @@ func Enabled() bool { return settings.GetBool(settings.UnboundedKey) } -// SetEnabled flips the local opt-in. When enabling, the proxy starts -// immediately if all three start conditions hold (local toggle + server -// feature flag + server config cached); otherwise it stays stopped and -// the next NewConfigEvent will reevaluate. When disabling, the proxy -// stops. Idempotent — calling with the current value is a no-op. +// SetEnabled persists the local opt-in (if it differs from the current +// persisted value) and re-evaluates the manager. Use this from direct +// callers (FFI, programmatic use) where the new toggle value hasn't +// been written to settings yet. +// +// PatchSettings persists settings itself before calling into the +// unbounded package, so it should use Apply() directly instead of +// going through SetEnabled — otherwise SetEnabled's no-change short- +// circuit (Enabled() == enable) returns before Apply runs and the +// manager never re-evaluates. func SetEnabled(enable bool) error { - if Enabled() == enable { - return nil - } - if err := settings.Set(settings.UnboundedKey, enable); err != nil { - return err + if Enabled() != enable { + if err := settings.Set(settings.UnboundedKey, enable); err != nil { + return err + } + slog.Info("Unbounded widget proxy local opt-in changed", "enabled", enable) } - slog.Info("Unbounded widget proxy local opt-in changed", "enabled", enable) - if !enable { + return Apply() +} + +// Apply re-evaluates the three-condition predicate (local toggle + +// server feature flag + server config cached) against the currently +// persisted setting and starts or stops the manager accordingly. Used +// by PatchSettings (which already persisted UnboundedKey itself) and +// by SetEnabled (after its persist step). Safe to call when nothing +// has changed — start is a no-op if the worker is already running and +// stop is a no-op if it isn't. +func Apply() error { + if !Enabled() { manager.stop() return nil } @@ -108,9 +135,12 @@ func SetEnabled(enable bool) error { shouldStart := manager.shouldStart() cfg := manager.lastCfg feature := manager.lastFeatureOn + running := manager.cancel != nil manager.mu.Unlock() if shouldStart { - manager.start(cfg) + if !running { + manager.start(cfg) + } return nil } switch { @@ -156,12 +186,31 @@ func InitSubscription() { var initOnce sync.Once -// Stop tears down a running widget proxy. Idempotent. Used as a -// LocalBackend shutdown hook so the broflake goroutines don't outlive -// the radiance process during a graceful exit. -func Stop(_ context.Context) error { +// Stop tears down a running widget proxy and waits for the worker +// goroutine to actually exit (or the supplied ctx to expire). Used +// as a LocalBackend shutdown hook — without the wait, Close would +// return as soon as the cancel signal was queued and the broflake +// goroutine could still be inside NewBroflake or ui.Stop when the +// rest of the process tears down. +// +// Idempotent: no-op if no worker is running. Returns ctx.Err() if +// the wait deadline expires before the worker exits — in that case +// the worker has been signalled to cancel and will exit on its own +// schedule, but the caller has given up waiting. +func Stop(ctx context.Context) error { + manager.mu.Lock() + done := manager.done + manager.mu.Unlock() manager.stop() - return nil + if done == nil { + return nil + } + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } } func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { @@ -172,9 +221,12 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { } ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) m.cancel = cancel + m.done = done go func() { + defer close(done) slog.Info("Unbounded: starting broflake widget proxy") bfOpt := clientcore.NewDefaultBroflakeOptions() @@ -234,6 +286,7 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { cancel() m.mu.Lock() m.cancel = nil + m.done = nil m.mu.Unlock() return } @@ -244,6 +297,7 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { ui.Stop() m.mu.Lock() m.cancel = nil + m.done = nil m.mu.Unlock() slog.Info("Unbounded: broflake widget proxy stopped") }() From 4e6bbb5242b783512f8396b59bf446f9a9504cd9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 15:44:42 -0600 Subject: [PATCH 47/63] unbounded/ipc: address Copilot review on #501 (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unbounded/unbounded.go: - Stop/Start race fix. Round-2's stop() cleared m.cancel immediately after signalling cancel, while the worker goroutine continued running through ui.Stop. During that window a concurrent Apply() or config event could see m.cancel == nil and spin up a second broflake widget while the first was still tearing down. Stop() likewise only waited on the done channel it captured BEFORE the restart. Fix: serialize start/stop transitions via a separate transitionMu. start() holds it for the brief window of setting up cancel/done. stop() holds it for the full cancel-then-wait-on-done cycle. Public Stop(ctx) does the same with a context-bounded wait. m.mu now only protects field reads/writes; never held across the done wait or any broflake call. Lock order is transitionMu → mu, consistent across all callers. - Drop ipc/server.go reference from the ConnectionEvent doc; rephrase to describe the in-process event-bus contract directly without naming the bridge's source location. ipc/client_events_{nonmobile,mobile}.go: - Add UnboundedConnectionEvents method mirroring PeerConnectionEvents. The server SSE route landed without a corresponding client method, leaving Liblantern / Flutter callers to hard-code the private path. Mobile path follows the existing localOnly+SSE dual-pattern. go.mod: - go mod tidy promoted broflake from // indirect to direct since unbounded/unbounded.go now imports clientcore directly. Co-Authored-By: Claude Opus 4.7 --- go.mod | 2 +- ipc/client_events_mobile.go | 19 +++++++++ ipc/client_events_nonmobile.go | 17 ++++++++ unbounded/unbounded.go | 77 ++++++++++++++++++++++------------ 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 07d0cc5d..45242f1d 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/alexflint/go-arg v1.6.1 github.com/alitto/pond v1.9.2 github.com/getlantern/amp v0.0.0-20260305201851-782bc8045e58 + github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 github.com/getlantern/common v1.2.1-0.20260326210434-cb69537aaf46 github.com/getlantern/dnstt v0.0.0-20260112160750-05100563bd0d github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 @@ -113,7 +114,6 @@ require ( github.com/gaissmai/bart v0.11.1 // indirect github.com/gaukas/wazerofs v0.1.0 // indirect github.com/getlantern/algeneva v0.0.0-20250307163401-1824e7b54f52 // indirect - github.com/getlantern/broflake v0.0.0-20260504215251-ed3cf75062d1 // indirect github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395 // indirect github.com/getlantern/samizdat v0.0.3-0.20260529191731-5ea8ae61ddbf // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect diff --git a/ipc/client_events_mobile.go b/ipc/client_events_mobile.go index a3a8dfd2..e0f1a52c 100644 --- a/ipc/client_events_mobile.go +++ b/ipc/client_events_mobile.go @@ -10,6 +10,7 @@ import ( "github.com/getlantern/radiance/config" "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/peer" + "github.com/getlantern/radiance/unbounded" "github.com/getlantern/radiance/vpn" ) @@ -95,3 +96,21 @@ func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.Con } }) } + +// UnboundedConnectionEvents — see client_events_nonmobile.go for the +// full docstring. Same mobile dual-path: localOnly subscribes directly +// to the in-process event bus, otherwise the SSE retry loop matches +// the desktop path. +func (c *Client) UnboundedConnectionEvents(ctx context.Context, handler func(unbounded.ConnectionEvent)) error { + events.SubscribeContext(ctx, handler) + if c.localOnly { + <-ctx.Done() + return ctx.Err() + } + return c.sseRetryLoop(ctx, unboundedConnectionEventsEndpoint, func(data []byte) { + var evt unbounded.ConnectionEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} diff --git a/ipc/client_events_nonmobile.go b/ipc/client_events_nonmobile.go index e0330fe1..ee15f9ce 100644 --- a/ipc/client_events_nonmobile.go +++ b/ipc/client_events_nonmobile.go @@ -8,6 +8,7 @@ import ( "github.com/getlantern/radiance/account" "github.com/getlantern/radiance/peer" + "github.com/getlantern/radiance/unbounded" "github.com/getlantern/radiance/vpn" ) @@ -74,3 +75,19 @@ func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.Con } }) } + +// UnboundedConnectionEvents streams accept/close events for the +// local broflake widget proxy ("Unbounded" / Basic mode). The JSON +// shape matches peer.ConnectionEvent but the Go type is distinct — +// in-process subscribers must subscribe to both event types separately +// to see all peer activity. State is +1 on consumer accept, -1 on +// close; Source is the consumer's IP if broflake exposes it, +// otherwise empty. Blocks until ctx is cancelled. +func (c *Client) UnboundedConnectionEvents(ctx context.Context, handler func(unbounded.ConnectionEvent)) error { + return c.sseRetryLoop(ctx, unboundedConnectionEventsEndpoint, func(data []byte) { + var evt unbounded.ConnectionEvent + if err := json.Unmarshal(data, &evt); err == nil { + handler(evt) + } + }) +} diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 465a834c..59a9f206 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -47,18 +47,17 @@ import ( // Source consumer's IP if broflake exposes it, otherwise empty // Timestamp emit time in Unix milliseconds // -// JSON shape is identical to radiance/peer.ConnectionEvent so a -// consumer reading both streams over IPC SSE can deserialize each -// frame with the same struct. In-process subscribers, however, -// receive these events on a separate channel — events.Subscribe is -// keyed by concrete Go type, so Subscribe[peer.ConnectionEvent] and -// Subscribe[unbounded.ConnectionEvent] are independent. The IPC -// bridge in ipc/server.go fans the two SSE endpoints into the -// unified peer-connection-events stream that the Flutter globe -// consumes. Broflake's internal worker-slot identifier is not -// surfaced — a consumer that needs to pair accept/close events for -// the same arc keys off Source (or arrival sequence within a single -// connection lifetime). +// JSON shape is identical to peer.ConnectionEvent so a consumer +// reading both SSE streams can deserialize each frame with the +// same struct. The in-process event bus, however, keys +// subscriptions by concrete Go type, so subscribing to +// peer.ConnectionEvent does NOT also deliver unbounded +// ConnectionEvents — in-process consumers that want a unified +// view of all peer activity must subscribe to both. Broflake's +// internal worker-slot identifier is not surfaced; a consumer +// that needs to pair accept/close events for the same arc keys +// off Source (or arrival sequence within a single connection +// lifetime). type ConnectionEvent struct { events.Event State int `json:"state"` @@ -69,14 +68,25 @@ type ConnectionEvent struct { var manager = &unboundedManager{} type unboundedManager struct { + // transitionMu serializes start/stop. It's held for the full + // duration of a stop (including the wait for the worker goroutine + // to actually exit) and for the full duration of a start. Without + // it, stop's signal-then-return path could race a concurrent start + // — the worker is still running ui.Stop while cancel/done get + // re-armed under a fresh worker, leaving two broflake widgets + // alive simultaneously. + transitionMu sync.Mutex + + // mu protects the fields below. Held only for the brief window of + // reading or mutating manager state; never held across the wait on + // done or any broflake call. mu sync.Mutex cancel context.CancelFunc // done is closed by the worker goroutine when it actually exits - // (after NewBroflake returns and ui.Stop runs). Stop callers wait - // on this so backend shutdown blocks until the broflake widget is - // actually torn down — without it, Stop returns as soon as the - // cancel signal is queued and the rest of LocalBackend.Close can - // race the still-running worker. Nil when nothing is running. + // (after NewBroflake returns and ui.Stop runs). stop and Stop wait + // on this under transitionMu so backend shutdown blocks until the + // broflake widget is actually torn down. Nil when nothing is + // running. done chan struct{} // lastCfg + lastFeatureOn cache the server-side half of the // three-condition predicate so SetEnabled can re-evaluate @@ -198,13 +208,16 @@ var initOnce sync.Once // the worker has been signalled to cancel and will exit on its own // schedule, but the caller has given up waiting. func Stop(ctx context.Context) error { + manager.transitionMu.Lock() + defer manager.transitionMu.Unlock() manager.mu.Lock() + cancel := manager.cancel done := manager.done manager.mu.Unlock() - manager.stop() - if done == nil { + if cancel == nil { return nil } + cancel() select { case <-done: return nil @@ -214,16 +227,19 @@ func Stop(ctx context.Context) error { } func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { + m.transitionMu.Lock() + defer m.transitionMu.Unlock() + m.mu.Lock() - defer m.mu.Unlock() if m.cancel != nil { - return // already running + m.mu.Unlock() + return // already running; transitionMu prevents overlap with stop } - ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) m.cancel = cancel m.done = done + m.mu.Unlock() go func() { defer close(done) @@ -303,11 +319,20 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { }() } +// stop signals the worker to exit and blocks until it does. Held +// under transitionMu so the worker fully unwinds (ui.Stop completes, +// m.cancel/m.done are cleared) before any other transition can +// observe state. func (m *unboundedManager) stop() { + m.transitionMu.Lock() + defer m.transitionMu.Unlock() m.mu.Lock() - defer m.mu.Unlock() - if m.cancel != nil { - m.cancel() - m.cancel = nil + cancel := m.cancel + done := m.done + m.mu.Unlock() + if cancel == nil { + return } + cancel() + <-done } From 89bbcaee982545a480e6eb08a111ccf57b215d0f Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 16:07:44 -0600 Subject: [PATCH 48/63] unbounded: seed cached config + add lifecycle tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot findings on round 3: 1) Cold-start race: InitSubscription only reacted to future NewConfigEvents, but ConfigHandler typically has a cached config loaded from disk by the time Start runs. An already- opted-in user wouldn't auto-start the widget on launch — the three-condition predicate stayed stuck at lastCfg=nil/ lastFeatureOn=false until the next config refresh arrived. Fix: InitSubscription now takes an optional initial *config.Config. If non-nil, the same applyConfig handler runs immediately on subscribe so the cached config seeds lastCfg/lastFeatureOn and the start transition fires synchronously. backend/radiance.go passes confHandler.GetConfig() (ignoring the not-yet-fetched error) so the seed is best-effort. 2) No tests for the Unbounded lifecycle. The manager coordinates persisted settings, server config gates, and start/stop concurrency — exactly the kind of code that regresses silently. Added unbounded_test.go covering: - shouldStart predicate, all 6 (toggle, feature, cfg) combos - Apply: disabled is no-op; with all conditions, exactly one widget starts; double-Apply is idempotent - Stop_WaitsForWorker: stop blocks until worker's ui.Stop returns (the round-2 fix's invariant) - StartDuringStop_NoOverlap: the round-2 regression guard — a concurrent start while a stop is in flight must NOT spin up a second widget; transitionMu serializes them - InitSubscription_SeedsCachedConfig: passing initial cfg kicks off applyConfig immediately - InitSubscription_FutureEventStillFires: live NewConfigEvent handler still works when initial cfg is nil - StopCtx_TimesOut: Stop(ctx) returns DeadlineExceeded when the worker can't unwind in time To make this testable without spinning up real WebRTC, extracted a small widget interface (Stop()) and an injectable newWidget factory. Default still calls clientcore.NewBroflake; tests swap in a fake that records start/stop calls under their own gates. TestMain initializes settings once for the binary — calling InitSettings per t.TempDir() runs into its sync.Once guard, leaving the package pointing at a since-cleaned-up temp dir. resetManager's cleanup waits on the worker's done channel before swapping newWidget back, so the race detector doesn't flag the worker's earlier read of newWidget against the cleanup's later write. Co-Authored-By: Claude Opus 4.7 --- backend/radiance.go | 7 +- unbounded/test_helpers_test.go | 14 ++ unbounded/unbounded.go | 91 +++++--- unbounded/unbounded_test.go | 410 +++++++++++++++++++++++++++++++++ 4 files changed, 494 insertions(+), 28 deletions(-) create mode 100644 unbounded/test_helpers_test.go create mode 100644 unbounded/unbounded_test.go diff --git a/backend/radiance.go b/backend/radiance.go index b657f402..c0e1c473 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -238,7 +238,12 @@ func (r *LocalBackend) Start() { // triggers (local toggle, server feature flag, server-supplied // config); InitSubscription is sync.Once-guarded so a future Start // retry after Close won't double-subscribe. - unbounded.InitSubscription() + // + // Seed with the already-cached config (loaded from disk before + // Start runs) so an opted-in user auto-starts the widget on + // launch instead of waiting for the next config refresh. + cachedCfg, _ := r.confHandler.GetConfig() + unbounded.InitSubscription(cachedCfg) // set country code in settings when new config is received so it can be included in issue reports events.SubscribeOnce(func(evt config.NewConfigEvent) { diff --git a/unbounded/test_helpers_test.go b/unbounded/test_helpers_test.go new file mode 100644 index 00000000..20262bc6 --- /dev/null +++ b/unbounded/test_helpers_test.go @@ -0,0 +1,14 @@ +package unbounded + +// countingWidget is a fakeWidget variant whose Stop runs a caller- +// supplied callback before returning. Used by TestStartDuringStop +// to decrement the live-widget counter under the test's own gate. +type countingWidget struct { + onStop func() +} + +func (w *countingWidget) Stop() { + if w.onStop != nil { + w.onStop() + } +} diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 59a9f206..56dbc016 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -67,6 +67,28 @@ type ConnectionEvent struct { var manager = &unboundedManager{} +// widget is the minimum interface the manager needs from a running +// broflake instance. Defined locally (vs using clientcore.UI) so +// tests can supply a tiny fake without implementing the full +// clientcore.UI surface area. +type widget interface { + Stop() +} + +// newWidget builds the live broflake widget. Package var so unit +// tests can swap it for a fake that records start/stop calls +// without spinning up real WebRTC. +var newWidget = func(bfOpt *clientcore.BroflakeOptions, rtcOpt *clientcore.WebRTCOptions, egOpt *clientcore.EgressOptions) (widget, error) { + // BroflakeConn is for clients routing traffic *through* the mesh. + // A widget proxy only donates bandwidth, so the conn is unused — + // discard it. + _, ui, err := clientcore.NewBroflake(bfOpt, rtcOpt, egOpt) + if err != nil { + return nil, err + } + return ui, nil +} + type unboundedManager struct { // transitionMu serializes start/stop. It's held for the full // duration of a stop (including the wait for the worker goroutine @@ -162,38 +184,56 @@ func Apply() error { return nil } -// InitSubscription wires the manager into radiance's config event bus. -// Called once at LocalBackend startup; the subscription lives for the -// process lifetime, so repeated calls would leak goroutines — hence -// the package-level guard. -func InitSubscription() { +// InitSubscription wires the manager into radiance's config event bus +// and applies any already-cached config. Called once at LocalBackend +// startup; the subscription lives for the process lifetime, so repeated +// calls would leak goroutines — hence the sync.Once guard. +// +// initial is the config that ConfigHandler has already loaded by the +// time Start reaches this line — typically the previously-persisted +// config from disk. Without seeding the manager state from it, the +// three-condition predicate stays stuck at lastCfg=nil/lastFeatureOn= +// false until the next config refresh arrives, and an already-opted-in +// user wouldn't auto-start the widget proxy until then. Pass nil if +// no config is available yet. +func InitSubscription(initial *config.Config) { initOnce.Do(func() { events.Subscribe(func(evt config.NewConfigEvent) { if evt.New == nil { return } - // config.Config is a type alias for C.ConfigResponse on - // the current radiance branch — no nested .ConfigResponse - // field, just dereference and use directly. - cfg := *evt.New - manager.mu.Lock() - manager.lastCfg = cfg.Unbounded - manager.lastFeatureOn = cfg.Features[C.UNBOUNDED] - shouldRun := manager.shouldStart() - running := manager.cancel != nil - ucfg := manager.lastCfg - manager.mu.Unlock() - - switch { - case shouldRun && !running: - manager.start(ucfg) - case !shouldRun && running: - manager.stop() - } + applyConfig(*evt.New) }) + if initial != nil { + applyConfig(*initial) + } }) } +// applyConfig caches the server-side half of the start predicate and +// transitions the manager start/stop accordingly. Shared by +// InitSubscription's NewConfigEvent handler and the initial-config +// seeding path so cached and live configs follow identical logic. +func applyConfig(cfg config.Config) { + manager.mu.Lock() + // config.Config is a type alias for C.ConfigResponse on the + // current radiance branch — no nested .ConfigResponse field, + // just dereference and use directly. + manager.lastCfg = cfg.Unbounded + manager.lastFeatureOn = cfg.Features[C.UNBOUNDED] + shouldRun := manager.shouldStart() + running := manager.cancel != nil + ucfg := manager.lastCfg + manager.mu.Unlock() + + switch { + case shouldRun && !running: + manager.start(ucfg) + case !shouldRun && running: + manager.stop() + } +} + var initOnce sync.Once // Stop tears down a running widget proxy and waits for the worker @@ -293,10 +333,7 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { } } - // BroflakeConn is for clients routing traffic *through* the - // mesh. A widget proxy only donates bandwidth, so the conn - // is unused — discard it. - _, ui, err := clientcore.NewBroflake(bfOpt, rtcOpt, egOpt) + ui, err := newWidget(bfOpt, rtcOpt, egOpt) if err != nil { slog.Error("Unbounded: failed to create broflake widget", "error", err) cancel() diff --git a/unbounded/unbounded_test.go b/unbounded/unbounded_test.go new file mode 100644 index 00000000..f25c60ea --- /dev/null +++ b/unbounded/unbounded_test.go @@ -0,0 +1,410 @@ +package unbounded + +import ( + "context" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + C "github.com/getlantern/common" + "github.com/getlantern/broflake/clientcore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/config" + "github.com/getlantern/radiance/events" +) + +// TestMain initializes the settings package once for the whole test +// binary. settings.InitSettings is itself a sync.Once-guarded +// installer (it persists the path to k.filePath), so calling it per +// t.TempDir() leaves the settings layer pointing at a directory the +// testing infra has already cleaned up by the time the second test +// runs — every subsequent settings.Set then fails with ENOENT. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "radiance-unbounded-settings-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + if err := settings.InitSettings(dir); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +// fakeWidget is a stand-in for the live broflake UI. stopBlock, if +// non-nil, lets a test pin Stop() until the test releases it — used +// to drive the "stop must wait for the worker" assertions. +type fakeWidget struct { + stopCalled atomic.Int32 + stopBlock chan struct{} +} + +func (w *fakeWidget) Stop() { + w.stopCalled.Add(1) + if w.stopBlock != nil { + <-w.stopBlock + } +} + +// resetManager swaps the package-level manager + widget factory and +// resets the UnboundedKey setting. Cleanup restores everything so +// tests don't bleed into each other. +func resetManager(t *testing.T, build func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error)) { + t.Helper() + require.NoError(t, settings.Set(settings.UnboundedKey, false)) + + prevManager := manager + prevWidget := newWidget + prevInit := initOnce + manager = &unboundedManager{} + newWidget = build + initOnce = sync.Once{} + t.Cleanup(func() { + // Wait for any still-live worker on the test's manager to + // exit before swapping the package-level newWidget back. + // Without this, the worker's read of newWidget (when the + // fake created it) races with the cleanup's write at test + // teardown — the race detector flags it even though the + // worker has already finished its call. Tests that leave a + // pinned worker (e.g. TestStopCtx_TimesOut) must release + // it before returning so this wait completes. + manager.mu.Lock() + done := manager.done + manager.mu.Unlock() + if done != nil { + <-done + } + manager = prevManager + newWidget = prevWidget + initOnce = prevInit + _ = settings.Set(settings.UnboundedKey, false) + }) +} + +// waitForRunning polls m.cancel under m.mu until it matches expected, +// or the deadline expires. The start goroutine sets m.cancel under +// m.mu before kicking off the worker, so this is a sufficient signal +// that a transition has been requested — note that for "true" the +// worker's newWidget call may still be pending. +func waitForRunning(t *testing.T, m *unboundedManager, expected bool, dur time.Duration) { + t.Helper() + deadline := time.Now().Add(dur) + for time.Now().Before(deadline) { + m.mu.Lock() + running := m.cancel != nil + m.mu.Unlock() + if running == expected { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("waitForRunning: timed out waiting for running=%v", expected) +} + +// waitForCount polls an int32 atomic until it equals want, or the +// deadline expires. Used in place of a flat-sleep when waiting for +// the start goroutine to call newWidget. +func waitForCount(t *testing.T, v *atomic.Int32, want int32, dur time.Duration) { + t.Helper() + deadline := time.Now().Add(dur) + for time.Now().Before(deadline) { + if v.Load() == want { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("waitForCount: timed out waiting for count=%d, got %d", want, v.Load()) +} + +func TestShouldStart(t *testing.T) { + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + return &fakeWidget{}, nil + }) + + tests := []struct { + name string + toggle bool + feature bool + cfg *C.UnboundedConfig + want bool + }{ + {"all off", false, false, nil, false}, + {"toggle only", true, false, nil, false}, + {"feature+cfg, no toggle", false, true, &C.UnboundedConfig{}, false}, + {"toggle+feature, no cfg", true, true, nil, false}, + {"toggle+cfg, no feature", true, false, &C.UnboundedConfig{}, false}, + {"all three", true, true, &C.UnboundedConfig{}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, settings.Set(settings.UnboundedKey, tc.toggle)) + manager.mu.Lock() + manager.lastFeatureOn = tc.feature + manager.lastCfg = tc.cfg + got := manager.shouldStart() + manager.mu.Unlock() + assert.Equal(t, tc.want, got) + }) + } +} + +// TestApply_DisabledIsNoop: Apply() returns immediately when the +// local toggle is off, regardless of cached server state. +func TestApply_DisabledIsNoop(t *testing.T) { + starts := atomic.Int32{} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + starts.Add(1) + return &fakeWidget{}, nil + }) + + manager.mu.Lock() + manager.lastFeatureOn = true + manager.lastCfg = &C.UnboundedConfig{} + manager.mu.Unlock() + require.NoError(t, Apply()) + + // Allow any spurious goroutine a beat to land. + time.Sleep(20 * time.Millisecond) + assert.Equal(t, int32(0), starts.Load(), "Apply() must not start widget when toggle is off") +} + +// TestApply_StartsWhenAllConditionsHold: with toggle on + cached +// feature flag + cached config, Apply() spins up exactly one widget. +func TestApply_StartsWhenAllConditionsHold(t *testing.T) { + fw := &fakeWidget{} + starts := atomic.Int32{} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + starts.Add(1) + return fw, nil + }) + + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + manager.mu.Lock() + manager.lastFeatureOn = true + manager.lastCfg = &C.UnboundedConfig{} + manager.mu.Unlock() + require.NoError(t, Apply()) + + waitForRunning(t, manager, true, 1*time.Second) + waitForCount(t, &starts, 1, 1*time.Second) + + // Double-Apply should not start a second widget. + require.NoError(t, Apply()) + time.Sleep(20 * time.Millisecond) + assert.Equal(t, int32(1), starts.Load()) + + // Tear down. + require.NoError(t, settings.Set(settings.UnboundedKey, false)) + require.NoError(t, Apply()) + waitForRunning(t, manager, false, 1*time.Second) + assert.Equal(t, int32(1), fw.stopCalled.Load()) +} + +// TestStop_WaitsForWorker: stop() blocks until the worker's ui.Stop +// returns. Pin ui.Stop with stopBlock and observe stop()'s wait. +func TestStop_WaitsForWorker(t *testing.T) { + block := make(chan struct{}) + fw := &fakeWidget{stopBlock: block} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + return fw, nil + }) + + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + manager.mu.Lock() + manager.lastFeatureOn = true + manager.lastCfg = &C.UnboundedConfig{} + manager.mu.Unlock() + require.NoError(t, Apply()) + waitForRunning(t, manager, true, 1*time.Second) + + stopReturned := make(chan struct{}) + go func() { + manager.stop() + close(stopReturned) + }() + select { + case <-stopReturned: + t.Fatal("stop() returned before fake widget's Stop unblocked") + case <-time.After(100 * time.Millisecond): + } + close(block) + select { + case <-stopReturned: + case <-time.After(1 * time.Second): + t.Fatal("stop() did not return after fake widget's Stop unblocked") + } + assert.Equal(t, int32(1), fw.stopCalled.Load()) +} + +// TestStartDuringStop_NoOverlap: regression guard for the round-2 +// race. Round-2's stop() cleared m.cancel immediately after +// cancel(); while the worker was still inside ui.Stop, a concurrent +// start() could see m.cancel == nil and spin up a second widget. +// +// We exercise the manager directly (skipping Apply's predicate +// check) so the test pins the exact transitionMu invariant: at any +// instant, at most one widget is between newWidget and Stop's +// return. Two widgets alive simultaneously means transitionMu +// failed to serialize. +func TestStartDuringStop_NoOverlap(t *testing.T) { + var ( + liveCount atomic.Int32 + maxLive atomic.Int32 + stopGate = make(chan struct{}) + ) + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + v := liveCount.Add(1) + if v > maxLive.Load() { + maxLive.Store(v) + } + return &countingWidget{onStop: func() { + <-stopGate + liveCount.Add(-1) + }}, nil + }) + + // Start the first widget directly via manager.start. + manager.start(&C.UnboundedConfig{}) + waitForCount(t, &liveCount, 1, 1*time.Second) + + // Kick off a stop — it'll block on stopGate inside the fake's + // onStop until we release it. + stopDone := make(chan struct{}) + go func() { + manager.stop() + close(stopDone) + }() + + // Once the stop is in flight (worker has received cancel and + // entered onStop), launch a concurrent start. transitionMu must + // hold this until the prior stop returns. + time.Sleep(50 * time.Millisecond) + startDone := make(chan struct{}) + go func() { + manager.start(&C.UnboundedConfig{}) + close(startDone) + }() + + // Neither should have completed yet. + time.Sleep(50 * time.Millisecond) + select { + case <-stopDone: + t.Fatal("stop returned before stopGate released") + case <-startDone: + t.Fatal("start returned before prior stop completed") + default: + } + + // Release the first widget's Stop. stop() returns, transitionMu + // frees, start() acquires it and creates widget #2. + close(stopGate) + select { + case <-stopDone: + case <-time.After(1 * time.Second): + t.Fatal("stop did not return after gate release") + } + select { + case <-startDone: + case <-time.After(1 * time.Second): + t.Fatal("start did not return after stop completed") + } + + // Widget #2 should now be live. + waitForCount(t, &liveCount, 1, 1*time.Second) + require.Equal(t, int32(1), maxLive.Load(), "two widgets ran concurrently — transitionMu failed") + + // Cleanup: tear down widget #2. stopGate is already closed, so + // the worker's Stop returns immediately. + manager.stop() + waitForCount(t, &liveCount, 0, 1*time.Second) +} + +// TestInitSubscription_SeedsCachedConfig: passing a non-nil initial +// config to InitSubscription kicks off the same applyConfig path +// the live event subscriber takes. With all three conditions met, +// the widget should auto-start without a fresh NewConfigEvent. +func TestInitSubscription_SeedsCachedConfig(t *testing.T) { + starts := atomic.Int32{} + fw := &fakeWidget{} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + starts.Add(1) + return fw, nil + }) + + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + cfg := &config.Config{ + Features: map[string]bool{C.UNBOUNDED: true}, + Unbounded: &C.UnboundedConfig{}, + } + InitSubscription(cfg) + + waitForRunning(t, manager, true, 1*time.Second) + waitForCount(t, &starts, 1, 1*time.Second) + + // Cleanup. + require.NoError(t, settings.Set(settings.UnboundedKey, false)) + require.NoError(t, Apply()) + waitForRunning(t, manager, false, 1*time.Second) +} + +// TestInitSubscription_FutureEventStillFires: even with a nil +// initial, the subscription still reacts to a subsequent +// NewConfigEvent — confirms the seed didn't replace the live path. +func TestInitSubscription_FutureEventStillFires(t *testing.T) { + starts := atomic.Int32{} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + starts.Add(1) + return &fakeWidget{}, nil + }) + + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + InitSubscription(nil) + time.Sleep(20 * time.Millisecond) + assert.Equal(t, int32(0), starts.Load(), "should not start with no cached config") + + // Fire a NewConfigEvent with all three conditions satisfied. + cfg := config.Config{ + Features: map[string]bool{C.UNBOUNDED: true}, + Unbounded: &C.UnboundedConfig{}, + } + events.Emit(config.NewConfigEvent{New: &cfg}) + waitForRunning(t, manager, true, 1*time.Second) + waitForCount(t, &starts, 1, 1*time.Second) + + // Cleanup. + require.NoError(t, settings.Set(settings.UnboundedKey, false)) + require.NoError(t, Apply()) + waitForRunning(t, manager, false, 1*time.Second) +} + +// TestStopCtx_TimesOut: Stop(ctx) returns ctx.Err() when the worker +// is still mid-shutdown past the deadline. The worker is left to +// exit on its own schedule afterwards. +func TestStopCtx_TimesOut(t *testing.T) { + block := make(chan struct{}) + fw := &fakeWidget{stopBlock: block} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + return fw, nil + }) + + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + manager.mu.Lock() + manager.lastFeatureOn = true + manager.lastCfg = &C.UnboundedConfig{} + manager.mu.Unlock() + require.NoError(t, Apply()) + waitForRunning(t, manager, true, 1*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + err := Stop(ctx) + assert.ErrorIs(t, err, context.DeadlineExceeded) + + // Release the worker so the test exits cleanly. + close(block) +} From a5bb1ab386d17f1f36244fa021ebbe0e0a36bb02 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 16:35:40 -0600 Subject: [PATCH 49/63] unbounded/ipc: address Copilot review on #501 (round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unbounded/unbounded.go: - Round-3 fix serialized start/stop via transitionMu, but a config event firing during public Stop's <-done wait could still queue at transitionMu and start a fresh widget the moment Stop's caller returned — silently keeping broflake alive past the documented backend shutdown contract. Added an explicit armed gate on the manager: - InitSubscription sets armed=true (every call, not just the first — the underlying subscription is still sync.Once- guarded, but Start-after-Close needs to re-arm). - Public Stop sets armed=false inside transitionMu before waiting on done. - start, Apply, applyConfig all bail when !armed. - start re-checks armed AFTER acquiring transitionMu, so even a transition queued at transitionMu during Stop's wait stays a no-op. Net effect: once Stop returns, the manager is permanently off until the next InitSubscription. No further start path (Apply, applyConfig, manager.start) can revive the widget. ipc/client_events_mobile.go: - Per AGENTS.md, comments must not reference code locations. Inlined the full docstrings for PeerStatusEvents, PeerConnectionEvents, and UnboundedConnectionEvents instead of pointing at client_events_nonmobile.go. unbounded/unbounded_test.go: - TestMain: os.Exit bypasses deferred calls, so the tmp dir was leaked on every test invocation. Capture m.Run's exit code, RemoveAll, then exit with that code. - resetManager: initialize manager with armed=true so tests that call manager.start directly aren't blocked by the new gate. Tests exercising Stop's disarm path flip it explicitly. - Added TestStopDisarmsManager: after public Stop, Apply + applyConfig + manager.start are all no-ops; a fresh InitSubscription re-arms. - Added TestStartAfterStopWaiting_NoRevival: pin Stop in its <-done wait, queue a start at transitionMu, confirm the queued start sees armed=false after Stop releases and bails without creating a second widget. Co-Authored-By: Claude Opus 4.7 --- ipc/client_events_mobile.go | 34 ++++++--- unbounded/unbounded.go | 60 ++++++++++++++-- unbounded/unbounded_test.go | 136 +++++++++++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 19 deletions(-) diff --git a/ipc/client_events_mobile.go b/ipc/client_events_mobile.go index e0f1a52c..fb327ac8 100644 --- a/ipc/client_events_mobile.go +++ b/ipc/client_events_mobile.go @@ -63,10 +63,15 @@ func (c *Client) DataCapStream(ctx context.Context, handler func(account.DataCap return c.dataCapStream(ctx, handler) } -// PeerStatusEvents — see client_events_nonmobile.go for the full -// docstring. Mobile builds may share a process with radiance (localOnly) -// in which case events.SubscribeContext delivers directly; otherwise the -// SSE retry loop matches the desktop path. +// PeerStatusEvents streams peer-share lifecycle phase changes (mapping_port +// → registering → verifying → serving on Start, stopping → idle on Stop, +// error on failure). Each frame is a peer.StatusEvent JSON whose .Status +// is the live snapshot at the moment the event fired — consumers SHOULD +// re-render on every frame rather than diffing, since events.Emit's +// per-callback goroutine can land Start phases out of order. Mobile builds +// may share a process with radiance (localOnly), in which case +// events.SubscribeContext delivers directly; otherwise the SSE retry loop +// is used. Blocks until ctx is cancelled. func (c *Client) PeerStatusEvents(ctx context.Context, handler func(peer.StatusEvent)) error { events.SubscribeContext(ctx, handler) if c.localOnly { @@ -81,8 +86,12 @@ func (c *Client) PeerStatusEvents(ctx context.Context, handler func(peer.StatusE }) } -// PeerConnectionEvents — see client_events_nonmobile.go for the full -// docstring. Same mobile dual-path as PeerStatusEvents. +// PeerConnectionEvents streams accept/close events for the local +// samizdat-in inbound. State is +1 on accept and -1 on close; Source is +// the remote "ip:port" string for geo-lookup / abuse attribution. +// Same mobile dual-path as PeerStatusEvents (localOnly delivers via +// the in-process event bus; otherwise the SSE retry loop is used). +// Blocks until ctx is cancelled. func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.ConnectionEvent)) error { events.SubscribeContext(ctx, handler) if c.localOnly { @@ -97,10 +106,15 @@ func (c *Client) PeerConnectionEvents(ctx context.Context, handler func(peer.Con }) } -// UnboundedConnectionEvents — see client_events_nonmobile.go for the -// full docstring. Same mobile dual-path: localOnly subscribes directly -// to the in-process event bus, otherwise the SSE retry loop matches -// the desktop path. +// UnboundedConnectionEvents streams accept/close events for the local +// broflake widget proxy ("Unbounded" / Basic mode). The JSON shape +// matches peer.ConnectionEvent but the Go type is distinct — in-process +// subscribers must subscribe to both event types separately to see all +// peer activity. State is +1 on consumer accept, -1 on close; Source +// is the consumer's IP if broflake exposes it, otherwise empty. Same +// mobile dual-path: localOnly subscribes directly to the in-process +// event bus; otherwise the SSE retry loop is used. Blocks until ctx +// is cancelled. func (c *Client) UnboundedConnectionEvents(ctx context.Context, handler func(unbounded.ConnectionEvent)) error { events.SubscribeContext(ctx, handler) if c.localOnly { diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 56dbc016..44c3e36f 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -102,7 +102,19 @@ type unboundedManager struct { // mu protects the fields below. Held only for the brief window of // reading or mutating manager state; never held across the wait on // done or any broflake call. - mu sync.Mutex + mu sync.Mutex + // armed gates every start path. InitSubscription flips it true; + // public Stop flips it false. Without this gate, a config event + // (or any other applyConfig caller) racing the public Stop's + // transitionMu hold could observe cancel==nil after Stop's wait, + // pile up at transitionMu, and start a new widget *after* the + // shutdown caller has already returned — the LocalBackend.Close + // docstring is explicit that Stop is the final teardown, so a + // post-Stop revival breaks that contract. start() and applyConfig + // re-check armed under mu (inside transitionMu for start) so a + // concurrent flip is honored even when the caller has been queued + // at transitionMu the whole time. + armed bool cancel context.CancelFunc // done is closed by the worker goroutine when it actually exits // (after NewBroflake returns and ui.Stop runs). stop and Stop wait @@ -158,17 +170,25 @@ func SetEnabled(enable bool) error { // by SetEnabled (after its persist step). Safe to call when nothing // has changed — start is a no-op if the worker is already running and // stop is a no-op if it isn't. +// +// No-op once Stop has disarmed the manager (post-shutdown): the +// armed gate is also checked inside start, so even a queued +// transition stays a no-op after Stop. func Apply() error { if !Enabled() { manager.stop() return nil } manager.mu.Lock() + armed := manager.armed shouldStart := manager.shouldStart() cfg := manager.lastCfg feature := manager.lastFeatureOn running := manager.cancel != nil manager.mu.Unlock() + if !armed { + return nil + } if shouldStart { if !running { manager.start(cfg) @@ -186,8 +206,10 @@ func Apply() error { // InitSubscription wires the manager into radiance's config event bus // and applies any already-cached config. Called once at LocalBackend -// startup; the subscription lives for the process lifetime, so repeated -// calls would leak goroutines — hence the sync.Once guard. +// startup; the underlying subscription lives for the process lifetime +// (sync.Once-guarded), but the armed flag is set on every call so a +// Start-after-Close re-enables the manager that public Stop had +// disarmed. // // initial is the config that ConfigHandler has already loaded by the // time Start reaches this line — typically the previously-persisted @@ -204,18 +226,27 @@ func InitSubscription(initial *config.Config) { } applyConfig(*evt.New) }) - if initial != nil { - applyConfig(*initial) - } }) + manager.mu.Lock() + manager.armed = true + manager.mu.Unlock() + if initial != nil { + applyConfig(*initial) + } } // applyConfig caches the server-side half of the start predicate and // transitions the manager start/stop accordingly. Shared by // InitSubscription's NewConfigEvent handler and the initial-config // seeding path so cached and live configs follow identical logic. +// No-op when the manager is disarmed (post-Stop) so a late event +// arriving after backend shutdown doesn't revive the widget. func applyConfig(cfg config.Config) { manager.mu.Lock() + if !manager.armed { + manager.mu.Unlock() + return + } // config.Config is a type alias for C.ConfigResponse on the // current radiance branch — no nested .ConfigResponse field, // just dereference and use directly. @@ -243,6 +274,14 @@ var initOnce sync.Once // goroutine could still be inside NewBroflake or ui.Stop when the // rest of the process tears down. // +// Stop also disarms the manager: any subsequent start path (Apply, +// applyConfig from a config event, manager.start directly) becomes +// a no-op until InitSubscription re-arms. The config subscription +// callback stays installed but short-circuits via the armed gate, +// so a late config event arriving during or after Stop can't +// revive the widget. Future Start (after Close) re-arms via +// InitSubscription. +// // Idempotent: no-op if no worker is running. Returns ctx.Err() if // the wait deadline expires before the worker exits — in that case // the worker has been signalled to cancel and will exit on its own @@ -251,6 +290,7 @@ func Stop(ctx context.Context) error { manager.transitionMu.Lock() defer manager.transitionMu.Unlock() manager.mu.Lock() + manager.armed = false cancel := manager.cancel done := manager.done manager.mu.Unlock() @@ -271,6 +311,14 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { defer m.transitionMu.Unlock() m.mu.Lock() + if !m.armed { + // Disarmed by public Stop. Re-check inside transitionMu so a + // start that got queued at transitionMu while Stop was waiting + // for the worker still bails out instead of reviving the widget + // after Stop's caller has returned. + m.mu.Unlock() + return + } if m.cancel != nil { m.mu.Unlock() return // already running; transitionMu prevents overlap with stop diff --git a/unbounded/unbounded_test.go b/unbounded/unbounded_test.go index f25c60ea..3872b77e 100644 --- a/unbounded/unbounded_test.go +++ b/unbounded/unbounded_test.go @@ -24,16 +24,23 @@ import ( // t.TempDir() leaves the settings layer pointing at a directory the // testing infra has already cleaned up by the time the second test // runs — every subsequent settings.Set then fails with ENOENT. +// +// os.Exit bypasses deferred calls, so the tmp-dir cleanup is done +// explicitly: capture m.Run's exit code, RemoveAll, then exit with +// that code. A naked defer + os.Exit would silently leak a directory +// per test invocation. func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "radiance-unbounded-settings-*") if err != nil { panic(err) } - defer os.RemoveAll(dir) if err := settings.InitSettings(dir); err != nil { + os.RemoveAll(dir) panic(err) } - os.Exit(m.Run()) + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) } // fakeWidget is a stand-in for the live broflake UI. stopBlock, if @@ -61,7 +68,10 @@ func resetManager(t *testing.T, build func(*clientcore.BroflakeOptions, *clientc prevManager := manager prevWidget := newWidget prevInit := initOnce - manager = &unboundedManager{} + // armed: true so direct manager.start calls in tests don't bail on + // the disarmed gate. Tests that exercise Stop's disarm behavior + // (TestStopDisarmsManager) flip it explicitly. + manager = &unboundedManager{armed: true} newWidget = build initOnce = sync.Once{} t.Cleanup(func() { @@ -382,6 +392,126 @@ func TestInitSubscription_FutureEventStillFires(t *testing.T) { waitForRunning(t, manager, false, 1*time.Second) } +// TestStopDisarmsManager: round-4 regression guard. After public +// Stop returns, NO subsequent start path may revive the widget — not +// Apply, not applyConfig from a late config event, not manager.start +// directly. The armed gate enforces this; transitionMu alone is not +// enough because it just serializes transitions, it doesn't decide +// whether a queued one should still proceed after a shutdown. +// +// Without this guard, a config event firing during Stop's wait-for- +// worker window would block at transitionMu, then start a fresh +// widget the moment Stop's caller has already returned from +// LocalBackend.Close — silently keeping broflake alive past the +// documented shutdown contract. +func TestStopDisarmsManager(t *testing.T) { + starts := atomic.Int32{} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + starts.Add(1) + return &fakeWidget{}, nil + }) + + // Bring up a widget so Stop has something to tear down. + manager.start(&C.UnboundedConfig{}) + waitForCount(t, &starts, 1, 1*time.Second) + + require.NoError(t, Stop(context.Background())) + manager.mu.Lock() + armed := manager.armed + manager.mu.Unlock() + require.False(t, armed, "Stop must disarm the manager") + + // All three start paths must be no-ops now. + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + manager.mu.Lock() + manager.lastFeatureOn = true + manager.lastCfg = &C.UnboundedConfig{} + manager.mu.Unlock() + + require.NoError(t, Apply()) + applyConfig(config.Config{ + Features: map[string]bool{C.UNBOUNDED: true}, + Unbounded: &C.UnboundedConfig{}, + }) + manager.start(&C.UnboundedConfig{}) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, int32(1), starts.Load(), "no start path may revive the widget post-Stop") + + // A fresh InitSubscription re-arms — Start-after-Close path. + initOnce = sync.Once{} // simulate re-arm path; InitSubscription's once guards re-subscription + InitSubscription(&config.Config{ + Features: map[string]bool{C.UNBOUNDED: true}, + Unbounded: &C.UnboundedConfig{}, + }) + waitForCount(t, &starts, 2, 1*time.Second) + + // Cleanup. + require.NoError(t, Stop(context.Background())) +} + +// TestStartAfterStopWaiting_NoRevival: pin Stop in its <-done wait +// (worker can't exit because its Stop is blocked), then race a +// config-event-driven start against the disarm. Confirms that even a +// start queued at transitionMu while Stop is waiting bails out after +// transitionMu releases — the armed re-check inside start catches it. +func TestStartAfterStopWaiting_NoRevival(t *testing.T) { + block := make(chan struct{}) + starts := atomic.Int32{} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + starts.Add(1) + return &fakeWidget{stopBlock: block}, nil + }) + + manager.start(&C.UnboundedConfig{}) + waitForCount(t, &starts, 1, 1*time.Second) + + // Stop in a goroutine — it'll set armed=false, signal cancel, + // then block on <-done waiting for the worker, which is in turn + // pinned by stopBlock. + stopDone := make(chan struct{}) + go func() { + _ = Stop(context.Background()) + close(stopDone) + }() + + // Give Stop a beat to acquire transitionMu and disarm. + time.Sleep(50 * time.Millisecond) + manager.mu.Lock() + armed := manager.armed + manager.mu.Unlock() + require.False(t, armed, "Stop must disarm before waiting on done") + + // Now race a start. It blocks at transitionMu until Stop returns. + startDone := make(chan struct{}) + go func() { + manager.start(&C.UnboundedConfig{}) + close(startDone) + }() + time.Sleep(20 * time.Millisecond) + select { + case <-startDone: + t.Fatal("start completed before Stop unblocked") + default: + } + + // Release the worker so Stop returns. + close(block) + select { + case <-stopDone: + case <-time.After(1 * time.Second): + t.Fatal("Stop did not return after worker released") + } + select { + case <-startDone: + case <-time.After(1 * time.Second): + t.Fatal("start did not return after Stop returned") + } + + // Critically: starts must still be 1 — the queued start saw + // armed=false and bailed. + assert.Equal(t, int32(1), starts.Load(), "start queued during Stop must not revive the widget") +} + // TestStopCtx_TimesOut: Stop(ctx) returns ctx.Err() when the worker // is still mid-shutdown past the deadline. The worker is left to // exit on its own schedule afterwards. From b1d4bae16fedd033813b4ecedea0556ef65cc1b5 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 30 May 2026 17:08:07 -0600 Subject: [PATCH 50/63] unbounded: restart on config-param change + drop round-X refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unbounded/unbounded.go: - broflake consumes its discovery / egress endpoints and table-size options once in clientcore.NewBroflake. Round-5's applyConfig only handled start/stop transitions: when the widget was already running and a server config refresh changed the UnboundedConfig parameters, the running proxy stayed on stale settings until a manual toggle or process restart. Manager now tracks runningCfg — the snapshot of UnboundedConfig the live worker was started with. applyConfig compares it against the freshly-cached lastCfg via cfgEqual (value equality on the flat string/int struct) and adds a new branch: shouldRun && running && cfgChanged → stop + start stop() blocks until the prior worker fully exits (transitionMu + <-done), so the restart-start always sees a clean slate. runningCfg is set in start() and cleared by the worker on both the success exit and the newWidget-error early-return paths. unbounded/unbounded_test.go: - Rewrote TestStartDuringStop_NoOverlap and TestStopDisarmsManager doc comments to state the invariant directly, dropping the 'round-2 / round-4 regression guard' framing. The test names + bodies are the durable record; the review-history framing was noise. - Added TestApplyConfig_RestartsOnParamChange covering the new restart branch: 1. apply cfg{DiscoverySrv: a} → widget #1 starts 2. apply same cfg → no restart (starts stays 1) 3. apply cfg{DiscoverySrv: b} → widget #2 starts (starts == 2) Co-Authored-By: Claude Opus 4.7 --- unbounded/unbounded.go | 43 ++++++++++++++++++ unbounded/unbounded_test.go | 89 ++++++++++++++++++++++++++++--------- 2 files changed, 110 insertions(+), 22 deletions(-) diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 44c3e36f..3844cf02 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -129,6 +129,16 @@ type unboundedManager struct { // new config arrives. lastCfg *C.UnboundedConfig lastFeatureOn bool + + // runningCfg is the snapshot of UnboundedConfig the live worker + // was started with. broflake consumes its discovery/egress + // options once in clientcore.NewBroflake, so a server-side config + // change while the worker is alive would otherwise leave it + // running on stale parameters. applyConfig compares this against + // the freshly-cached lastCfg and triggers stop+start when they + // differ, with the predicate still otherwise satisfied. Nil + // whenever cancel is nil. + runningCfg *C.UnboundedConfig } // shouldStart reports whether all three start conditions hold. Caller @@ -137,6 +147,21 @@ func (m *unboundedManager) shouldStart() bool { return settings.GetBool(settings.UnboundedKey) && m.lastFeatureOn && m.lastCfg != nil } +// cfgEqual reports whether two UnboundedConfig pointers refer to +// configurations broflake would consume identically. UnboundedConfig +// is a flat struct of strings and ints, so value equality is well- +// defined. Nil pointers compare equal to themselves and unequal to +// any non-nil pointer. +func cfgEqual(a, b *C.UnboundedConfig) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + // Enabled reports whether the local opt-in is set. Doesn't say whether // the proxy is currently running (server flag and config can override). func Enabled() bool { @@ -255,11 +280,21 @@ func applyConfig(cfg config.Config) { shouldRun := manager.shouldStart() running := manager.cancel != nil ucfg := manager.lastCfg + cfgChanged := running && !cfgEqual(manager.runningCfg, ucfg) manager.mu.Unlock() switch { case shouldRun && !running: manager.start(ucfg) + case shouldRun && cfgChanged: + // Broflake consumed its options at construction time and has + // no live-reconfigure API; the only way to pick up new + // discovery/egress endpoints or table sizes is to tear the + // worker down and bring it back up with the new config. + // stop blocks until the prior worker fully exits, so start + // always sees a clean slate. + manager.stop() + manager.start(ucfg) case !shouldRun && running: manager.stop() } @@ -327,6 +362,12 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { done := make(chan struct{}) m.cancel = cancel m.done = done + // Snapshot the config the worker is being started with so a + // later applyConfig can detect parameter changes and restart. + // Pointer-stored (not value-stored) because the upstream + // lastCfg is also a pointer and equality is value-based via + // cfgEqual. + m.runningCfg = ucfg m.mu.Unlock() go func() { @@ -388,6 +429,7 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { m.mu.Lock() m.cancel = nil m.done = nil + m.runningCfg = nil m.mu.Unlock() return } @@ -399,6 +441,7 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { m.mu.Lock() m.cancel = nil m.done = nil + m.runningCfg = nil m.mu.Unlock() slog.Info("Unbounded: broflake widget proxy stopped") }() diff --git a/unbounded/unbounded_test.go b/unbounded/unbounded_test.go index 3872b77e..ede748b5 100644 --- a/unbounded/unbounded_test.go +++ b/unbounded/unbounded_test.go @@ -251,16 +251,16 @@ func TestStop_WaitsForWorker(t *testing.T) { assert.Equal(t, int32(1), fw.stopCalled.Load()) } -// TestStartDuringStop_NoOverlap: regression guard for the round-2 -// race. Round-2's stop() cleared m.cancel immediately after -// cancel(); while the worker was still inside ui.Stop, a concurrent -// start() could see m.cancel == nil and spin up a second widget. -// -// We exercise the manager directly (skipping Apply's predicate -// check) so the test pins the exact transitionMu invariant: at any -// instant, at most one widget is between newWidget and Stop's -// return. Two widgets alive simultaneously means transitionMu -// failed to serialize. +// TestStartDuringStop_NoOverlap pins the transitionMu invariant: at +// any instant, at most one broflake widget is between newWidget and +// Stop's return. If stop() merely signalled cancel and returned +// (without holding transitionMu through the wait on done), a +// concurrent start() could observe m.cancel == nil and bring up a +// second widget while the first is still inside ui.Stop. The test +// exercises the manager directly (skipping Apply's predicate check) +// because the property being verified is local to start/stop +// serialization — two widgets alive simultaneously means +// transitionMu failed to serialize. func TestStartDuringStop_NoOverlap(t *testing.T) { var ( liveCount atomic.Int32 @@ -392,18 +392,63 @@ func TestInitSubscription_FutureEventStillFires(t *testing.T) { waitForRunning(t, manager, false, 1*time.Second) } -// TestStopDisarmsManager: round-4 regression guard. After public -// Stop returns, NO subsequent start path may revive the widget — not -// Apply, not applyConfig from a late config event, not manager.start -// directly. The armed gate enforces this; transitionMu alone is not -// enough because it just serializes transitions, it doesn't decide -// whether a queued one should still proceed after a shutdown. -// -// Without this guard, a config event firing during Stop's wait-for- -// worker window would block at transitionMu, then start a fresh -// widget the moment Stop's caller has already returned from -// LocalBackend.Close — silently keeping broflake alive past the -// documented shutdown contract. +// TestApplyConfig_RestartsOnParamChange: broflake consumes its +// options once in clientcore.NewBroflake. A server-side config +// change while the widget is alive must therefore tear down the +// current worker and bring up a new one — otherwise the running +// proxy stays on stale discovery/egress endpoints. applyConfig +// compares the new cfg against runningCfg (the snapshot the worker +// was started with) and triggers stop+start when they differ, +// provided the three-condition predicate still holds. +func TestApplyConfig_RestartsOnParamChange(t *testing.T) { + starts := atomic.Int32{} + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + starts.Add(1) + return &fakeWidget{}, nil + }) + + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + + // First config — bring up widget #1. + applyConfig(config.Config{ + Features: map[string]bool{C.UNBOUNDED: true}, + Unbounded: &C.UnboundedConfig{DiscoverySrv: "https://a.example/"}, + }) + waitForRunning(t, manager, true, 1*time.Second) + waitForCount(t, &starts, 1, 1*time.Second) + + // Same config — no restart. + applyConfig(config.Config{ + Features: map[string]bool{C.UNBOUNDED: true}, + Unbounded: &C.UnboundedConfig{DiscoverySrv: "https://a.example/"}, + }) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, int32(1), starts.Load(), "identical config must not restart") + + // Changed config — restart with new params. + applyConfig(config.Config{ + Features: map[string]bool{C.UNBOUNDED: true}, + Unbounded: &C.UnboundedConfig{DiscoverySrv: "https://b.example/"}, + }) + waitForCount(t, &starts, 2, 2*time.Second) + + // Cleanup. + require.NoError(t, settings.Set(settings.UnboundedKey, false)) + require.NoError(t, Apply()) + waitForRunning(t, manager, false, 1*time.Second) +} + +// TestStopDisarmsManager verifies the public-Stop shutdown +// contract: after Stop returns, NO subsequent start path may revive +// the widget — not Apply, not applyConfig from a late config event, +// not manager.start directly. The armed gate enforces this; +// transitionMu alone is not enough because it just serializes +// transitions, it doesn't decide whether a queued one should still +// proceed after a shutdown. Without this gate, a config event +// firing during Stop's wait-for-worker window would block at +// transitionMu and then start a fresh widget the moment Stop's +// caller returned from LocalBackend.Close, silently keeping +// broflake alive past the documented shutdown contract. func TestStopDisarmsManager(t *testing.T) { starts := atomic.Int32{} resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { From fab127d991ada97b9214652eeaaf8fd2565718b2 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 31 May 2026 05:37:41 -0600 Subject: [PATCH 51/63] unbounded: re-check predicate inside start + test event bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unbounded/unbounded.go: - start() now re-checks the full enablement predicate (shouldStart()) under m.mu, not just the armed gate. Without this, a caller could decide to start based on its earlier snapshot, get queued behind a stop or another transition at transitionMu, and then create a worker even though a concurrent SetEnabled(false) or config update had since disabled Unbounded. The widget would run with stale state until some later event stopped it. - start() also drops its ucfg parameter and reads m.lastCfg directly under the lock. applyConfig sets m.lastCfg before calling start, so the normal path is unchanged; for the race case (cfg updated between the caller's read and start's lock acquisition), the worker comes up with the latest parameters rather than a stale snapshot. unbounded/unbounded_test.go: - New primeManager(t, cfg) helper seeds the predicate fields (toggle on, lastFeatureOn=true, lastCfg=cfg) so the four tests that drive manager.start directly satisfy shouldStart() inside the new gate. - Added TestConnectionEventBridge: captures the OnConnectionChangeFunc that start installs on bfOpt via the fake newWidget, invokes it with both a non-nil and a nil net.IP, and asserts the resulting events.ConnectionEvent payloads via events.Subscribe — pins {state, source, timestamp} including the addr.String() / empty conversion for nil. Set-membership keyed by State because events.Emit dispatches each subscriber on a per-callback goroutine, so arrival order is not deterministic. Stress-tested 20x under -race -count=1, clean every run. Co-Authored-By: Claude Opus 4.7 --- unbounded/unbounded.go | 33 +++++++++-- unbounded/unbounded_test.go | 114 ++++++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 3844cf02..61a5e470 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -216,7 +216,7 @@ func Apply() error { } if shouldStart { if !running { - manager.start(cfg) + manager.start() } return nil } @@ -285,7 +285,7 @@ func applyConfig(cfg config.Config) { switch { case shouldRun && !running: - manager.start(ucfg) + manager.start() case shouldRun && cfgChanged: // Broflake consumed its options at construction time and has // no live-reconfigure API; the only way to pick up new @@ -294,7 +294,7 @@ func applyConfig(cfg config.Config) { // stop blocks until the prior worker fully exits, so start // always sees a clean slate. manager.stop() - manager.start(ucfg) + manager.start() case !shouldRun && running: manager.stop() } @@ -341,7 +341,22 @@ func Stop(ctx context.Context) error { } } -func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { +// start brings up the broflake worker if all preconditions hold at +// the moment it acquires the locks: manager armed, no worker already +// running, and the three-condition predicate (toggle + feature flag + +// cached config) still satisfied. Every check is done INSIDE +// transitionMu so a caller queued behind a stop or another start +// observes the freshest state rather than a snapshot from when it +// decided to start — a concurrent SetEnabled(false) or config update +// between the caller's predicate read and start's lock acquisition +// is honored. +// +// The config used by the worker is the LIVE m.lastCfg, not a snapshot +// captured by the caller. applyConfig updates m.lastCfg before +// calling start, so this gives identical behavior for the normal +// path; for the race case (config updated after caller decided to +// start), the worker comes up with the latest parameters. +func (m *unboundedManager) start() { m.transitionMu.Lock() defer m.transitionMu.Unlock() @@ -358,6 +373,16 @@ func (m *unboundedManager) start(ucfg *C.UnboundedConfig) { m.mu.Unlock() return // already running; transitionMu prevents overlap with stop } + if !m.shouldStart() { + // Predicate flipped while we were queued at transitionMu — a + // SetEnabled(false), a config event that cleared the feature + // flag or unset the cfg, or any other concurrent change. Bail + // rather than start a worker that's already been decided + // against. + m.mu.Unlock() + return + } + ucfg := m.lastCfg ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) m.cancel = cancel diff --git a/unbounded/unbounded_test.go b/unbounded/unbounded_test.go index ede748b5..f7e0c761 100644 --- a/unbounded/unbounded_test.go +++ b/unbounded/unbounded_test.go @@ -2,6 +2,7 @@ package unbounded import ( "context" + "net" "os" "sync" "sync/atomic" @@ -97,6 +98,24 @@ func resetManager(t *testing.T, build func(*clientcore.BroflakeOptions, *clientc } // waitForRunning polls m.cancel under m.mu until it matches expected, +// primeManager seeds the predicate fields so a direct manager.start() +// in a test will satisfy the shouldStart() check inside the lock: the +// UnboundedKey setting goes true, manager.lastFeatureOn = true, and +// manager.lastCfg = the supplied cfg (or a zero-value +// UnboundedConfig if nil). Tests that exercise the start path +// directly call this once after resetManager. +func primeManager(t *testing.T, cfg *C.UnboundedConfig) { + t.Helper() + require.NoError(t, settings.Set(settings.UnboundedKey, true)) + if cfg == nil { + cfg = &C.UnboundedConfig{} + } + manager.mu.Lock() + manager.lastFeatureOn = true + manager.lastCfg = cfg + manager.mu.Unlock() +} + // or the deadline expires. The start goroutine sets m.cancel under // m.mu before kicking off the worker, so this is a sufficient signal // that a transition has been requested — note that for "true" the @@ -278,8 +297,10 @@ func TestStartDuringStop_NoOverlap(t *testing.T) { }}, nil }) - // Start the first widget directly via manager.start. - manager.start(&C.UnboundedConfig{}) + // Prime predicate so direct manager.start calls satisfy + // shouldStart() inside the lock, then start the first widget. + primeManager(t, nil) + manager.start() waitForCount(t, &liveCount, 1, 1*time.Second) // Kick off a stop — it'll block on stopGate inside the fake's @@ -296,7 +317,7 @@ func TestStartDuringStop_NoOverlap(t *testing.T) { time.Sleep(50 * time.Millisecond) startDone := make(chan struct{}) go func() { - manager.start(&C.UnboundedConfig{}) + manager.start() close(startDone) }() @@ -457,7 +478,8 @@ func TestStopDisarmsManager(t *testing.T) { }) // Bring up a widget so Stop has something to tear down. - manager.start(&C.UnboundedConfig{}) + primeManager(t, nil) + manager.start() waitForCount(t, &starts, 1, 1*time.Second) require.NoError(t, Stop(context.Background())) @@ -466,19 +488,16 @@ func TestStopDisarmsManager(t *testing.T) { manager.mu.Unlock() require.False(t, armed, "Stop must disarm the manager") - // All three start paths must be no-ops now. - require.NoError(t, settings.Set(settings.UnboundedKey, true)) - manager.mu.Lock() - manager.lastFeatureOn = true - manager.lastCfg = &C.UnboundedConfig{} - manager.mu.Unlock() + // All three start paths must be no-ops now. Predicate is still + // satisfied (toggle, feature, cfg) — only armed gates the start. + primeManager(t, nil) require.NoError(t, Apply()) applyConfig(config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, Unbounded: &C.UnboundedConfig{}, }) - manager.start(&C.UnboundedConfig{}) + manager.start() time.Sleep(50 * time.Millisecond) assert.Equal(t, int32(1), starts.Load(), "no start path may revive the widget post-Stop") @@ -507,7 +526,8 @@ func TestStartAfterStopWaiting_NoRevival(t *testing.T) { return &fakeWidget{stopBlock: block}, nil }) - manager.start(&C.UnboundedConfig{}) + primeManager(t, nil) + manager.start() waitForCount(t, &starts, 1, 1*time.Second) // Stop in a goroutine — it'll set armed=false, signal cancel, @@ -529,7 +549,7 @@ func TestStartAfterStopWaiting_NoRevival(t *testing.T) { // Now race a start. It blocks at transitionMu until Stop returns. startDone := make(chan struct{}) go func() { - manager.start(&C.UnboundedConfig{}) + manager.start() close(startDone) }() time.Sleep(20 * time.Millisecond) @@ -583,3 +603,71 @@ func TestStopCtx_TimesOut(t *testing.T) { // Release the worker so the test exits cleanly. close(block) } + +// TestConnectionEventBridge pins the observable API this package +// adds: broflake's OnConnectionChangeFunc callback must translate +// (state, workerIdx, addr) into a ConnectionEvent with the matching +// State, the addr.String() Source (empty when addr is nil), and a +// freshly-stamped Timestamp. Capture the callback that start +// installs on bfOpt via a fake newWidget, invoke it with both nil +// and non-nil addrs, then assert the events arriving via +// events.Subscribe. +func TestConnectionEventBridge(t *testing.T) { + var captured atomic.Pointer[clientcore.ConnectionChangeFunc] + resetManager(t, func(bfOpt *clientcore.BroflakeOptions, _ *clientcore.WebRTCOptions, _ *clientcore.EgressOptions) (widget, error) { + cb := bfOpt.OnConnectionChangeFunc + captured.Store(&cb) + return &fakeWidget{}, nil + }) + + primeManager(t, nil) + manager.start() + // Worker may still be inside the goroutine setup when start + // returns; wait until newWidget has been called and the callback + // captured. + deadline := time.Now().Add(1 * time.Second) + for captured.Load() == nil && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + require.NotNil(t, captured.Load(), "newWidget was never invoked") + cb := *captured.Load() + require.NotNil(t, cb, "OnConnectionChangeFunc must be installed on bfOpt") + + // Buffered enough that events.Emit's per-callback goroutines + // can deposit before the test reads. + ch := make(chan ConnectionEvent, 4) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events.SubscribeContext(ctx, func(evt ConnectionEvent) { ch <- evt }) + + before := time.Now().UnixMilli() + cb(1, 7, net.ParseIP("198.51.100.42")) + cb(-1, 7, nil) + after := time.Now().UnixMilli() + + // events.Emit dispatches each subscriber on a per-callback + // goroutine, so the two events can arrive in either order. + // Assert set-membership keyed by State (unique in this test) + // rather than positional equality. + byState := make(map[int]ConnectionEvent, 2) + for i := 0; i < 2; i++ { + select { + case evt := <-ch: + byState[evt.State] = evt + case <-time.After(1 * time.Second): + t.Fatalf("timed out waiting for ConnectionEvent #%d", i+1) + } + } + + require.Contains(t, byState, 1, "expected an accept event (State=+1)") + require.Contains(t, byState, -1, "expected a close event (State=-1)") + assert.Equal(t, "198.51.100.42", byState[1].Source, "accept Source") + assert.Equal(t, "", byState[-1].Source, "close Source (nil addr -> empty string)") + for state, evt := range byState { + assert.GreaterOrEqual(t, evt.Timestamp, before, "State=%d Timestamp not before emit", state) + assert.LessOrEqual(t, evt.Timestamp, after, "State=%d Timestamp not after emit", state) + } + + // Cleanup. + require.NoError(t, Stop(context.Background())) +} From cdfb4e88b930e6c41eaa7b4d3608951baae75795 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 31 May 2026 14:31:22 -0600 Subject: [PATCH 52/63] unbounded: bound internal stop with a default timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manager.stop() (called from Apply and applyConfig) waited on the worker's done channel with no deadline. If broflake's ui.Stop hung, toggling Unbounded off via PatchSettings or receiving a disabling config event would block the caller indefinitely while holding transitionMu. Only the public Stop(ctx) was bounded. Extracted stopCtx(ctx, disarm) as the shared implementation for both paths: - Public Stop(ctx) calls stopCtx(ctx, true) — disarm + caller's own deadline (5s from LocalBackend.Close). - Internal manager.stop() calls stopCtx with a fresh context bounded by internalStopTimeout (default 5s). The timeout error is logged; callers have no useful action to take, and a subsequent start observes 'already running' until the worker eventually exits. internalStopTimeout is a var (not const) so tests can install a short timeout (50ms) and exercise the timeout path without holding up the suite. TestInternalStop_TimesOut: pins ui.Stop past the short timeout via stopBlock, flips the toggle off so Apply calls manager.stop, asserts Apply returns within 2s rather than blocking forever. Stress-tested 20x under -race -count=1, all clean. Co-Authored-By: Claude Opus 4.7 --- unbounded/unbounded.go | 66 ++++++++++++++++++++++++------------- unbounded/unbounded_test.go | 41 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 22 deletions(-) diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 61a5e470..4eb85539 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -320,15 +320,40 @@ var initOnce sync.Once // Idempotent: no-op if no worker is running. Returns ctx.Err() if // the wait deadline expires before the worker exits — in that case // the worker has been signalled to cancel and will exit on its own -// schedule, but the caller has given up waiting. +// schedule, but the caller has given up waiting. m.cancel and +// m.done stay set until the worker eventually clears them, so a +// subsequent start observes "already running" and is a no-op. func Stop(ctx context.Context) error { - manager.transitionMu.Lock() - defer manager.transitionMu.Unlock() - manager.mu.Lock() - manager.armed = false - cancel := manager.cancel - done := manager.done - manager.mu.Unlock() + return manager.stopCtx(ctx, true) +} + +// internalStopTimeout bounds how long Apply / applyConfig wait for +// the worker to exit after signalling cancel. broflake's ui.Stop +// should drain in well under this; a longer-than-expected ui.Stop +// must not block a settings PATCH or a config-event handler +// indefinitely. Variable (not const) so tests can install a short +// timeout to exercise the timeout path without holding up the +// suite. Production code reads this on every call, so the override +// applies for the duration of the test. +var internalStopTimeout = 5 * time.Second + +// stopCtx is the shared implementation for both the public Stop +// (disarm=true) and internal manager.stop (disarm=false). Holds +// transitionMu for the entire signal+wait so a concurrent start +// cannot interleave. On ctx expiration the cancel signal has been +// delivered and the worker will exit on its own schedule; manager +// state is left intact so future transitions see "already running" +// until the worker clears it. +func (m *unboundedManager) stopCtx(ctx context.Context, disarm bool) error { + m.transitionMu.Lock() + defer m.transitionMu.Unlock() + m.mu.Lock() + if disarm { + m.armed = false + } + cancel := m.cancel + done := m.done + m.mu.Unlock() if cancel == nil { return nil } @@ -472,20 +497,17 @@ func (m *unboundedManager) start() { }() } -// stop signals the worker to exit and blocks until it does. Held -// under transitionMu so the worker fully unwinds (ui.Stop completes, -// m.cancel/m.done are cleared) before any other transition can -// observe state. +// stop is the internal (no-arg) variant called by Apply and +// applyConfig. It wraps stopCtx with a default timeout so a hung +// ui.Stop doesn't block settings PATCHes or config-event handling +// indefinitely. The timeout error is logged because the call site +// has no useful action to take; subsequent transitions observe +// "already running" until the worker eventually exits. func (m *unboundedManager) stop() { - m.transitionMu.Lock() - defer m.transitionMu.Unlock() - m.mu.Lock() - cancel := m.cancel - done := m.done - m.mu.Unlock() - if cancel == nil { - return + ctx, cancel := context.WithTimeout(context.Background(), internalStopTimeout) + defer cancel() + if err := m.stopCtx(ctx, false); err != nil { + slog.Warn("Unbounded: internal stop timed out before worker exited", + "error", err, "timeout", internalStopTimeout) } - cancel() - <-done } diff --git a/unbounded/unbounded_test.go b/unbounded/unbounded_test.go index f7e0c761..54a473f8 100644 --- a/unbounded/unbounded_test.go +++ b/unbounded/unbounded_test.go @@ -671,3 +671,44 @@ func TestConnectionEventBridge(t *testing.T) { // Cleanup. require.NoError(t, Stop(context.Background())) } + +// TestInternalStop_TimesOut: internal stop (called by Apply and +// applyConfig) must not block forever when ui.Stop hangs. If it +// did, toggling Unbounded off via PatchSettings or a disabling +// config event would block the caller indefinitely while holding +// transitionMu. Pin ui.Stop with stopBlock past a short +// internalStopTimeout and confirm Apply returns within a sane +// budget. +func TestInternalStop_TimesOut(t *testing.T) { + prevTimeout := internalStopTimeout + internalStopTimeout = 50 * time.Millisecond + t.Cleanup(func() { internalStopTimeout = prevTimeout }) + + block := make(chan struct{}) + resetManager(t, func(*clientcore.BroflakeOptions, *clientcore.WebRTCOptions, *clientcore.EgressOptions) (widget, error) { + return &fakeWidget{stopBlock: block}, nil + }) + + primeManager(t, nil) + require.NoError(t, Apply()) + waitForRunning(t, manager, true, 1*time.Second) + + // Flip toggle off so Apply's !Enabled branch calls manager.stop. + // With the worker pinned by stopBlock, internal stop must time + // out rather than hang. Apply itself returns nil — the timeout + // is logged but not propagated. + require.NoError(t, settings.Set(settings.UnboundedKey, false)) + applyReturned := make(chan struct{}) + go func() { + _ = Apply() + close(applyReturned) + }() + select { + case <-applyReturned: + case <-time.After(2 * time.Second): + t.Fatal("Apply blocked past timeout — internal stop is not context-bounded") + } + + // Release the worker so the test's cleanup wait completes. + close(block) +} From 11aa55a6ff162acda05477bca01e6ca5c88581c1 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 31 May 2026 15:20:14 -0600 Subject: [PATCH 53/63] backend/unbounded: test PatchSettings dispatches UnboundedKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A typo on the diff key (settings.UnboundedKey → settings.UnboundedKye) or a removal of the unbounded.Apply() call would silently leave the UI toggle persisted but inert. The peer-share side has TestPatchSettings_PeerShareDispatches catching exactly this class of regression; mirror it for Unbounded. unbounded/unbounded.go: - Add applyHook: a nil-in-production function pointer invoked at the top of Apply(), before any state check. Backend tests install a counter via SetApplyHookForTest to verify dispatch. This is the smallest possible test surface — exposing the manager's internals across packages just for the test wiring would be a much bigger surface, and the hook fires regardless of the Enabled() gate so tests catch dispatch even when no transition results. backend/radiance_test.go: - TestPatchSettings_UnboundedDispatches: install the hook, PATCH {UnboundedKey:true}, assert hook fired once; PATCH {UnboundedKey:false}, assert hook fired twice. Then PATCH {PeerShareEnabledKey:false} (a key OTHER than UnboundedKey) and assert the counter doesn't move — confirms the diff check is in place rather than always firing. Sanity-checked the test by removing the Apply call from PatchSettings: test fails with 'expected 2, actual 0'. Restored: test passes. Co-Authored-By: Claude Opus 4.7 --- backend/radiance_test.go | 26 ++++++++++++++++++++++++++ unbounded/unbounded.go | 20 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/radiance_test.go b/backend/radiance_test.go index 238d19b9..10fe1bec 100644 --- a/backend/radiance_test.go +++ b/backend/radiance_test.go @@ -13,6 +13,7 @@ import ( "github.com/getlantern/radiance/common/settings" "github.com/getlantern/radiance/peer" + "github.com/getlantern/radiance/unbounded" ) func TestBackend(t *testing.T) {} @@ -216,3 +217,28 @@ func TestPatchSettings_PeerShareDispatches(t *testing.T) { assert.Equal(t, int64(1), fake.stopCalls.Load()) assert.False(t, fake.IsActive()) } + +// Verify PatchSettings routes UnboundedKey to unbounded.Apply via the +// SetApplyHookForTest hook. Companion to TestPatchSettings_PeerShareDispatches — +// a typo on the diff key or a removal of the Apply call would silently +// leave the Unbounded toggle persisted but inert. The hook fires +// regardless of the Enabled() gate inside Apply, so this catches the +// dispatch even though we don't prime the rest of the manager state. +func TestPatchSettings_UnboundedDispatches(t *testing.T) { + r := newPeerTestBackend(t, &fakePeerController{}) + + var applyCalls atomic.Int32 + unbounded.SetApplyHookForTest(func() { applyCalls.Add(1) }) + t.Cleanup(func() { unbounded.SetApplyHookForTest(nil) }) + + require.NoError(t, r.PatchSettings(settings.Settings{settings.UnboundedKey: true})) + assert.Equal(t, int32(1), applyCalls.Load(), "PatchSettings({UnboundedKey: true}) must dispatch to unbounded.Apply") + + require.NoError(t, r.PatchSettings(settings.Settings{settings.UnboundedKey: false})) + assert.Equal(t, int32(2), applyCalls.Load(), "PatchSettings({UnboundedKey: false}) must dispatch to unbounded.Apply") + + // A PATCH without UnboundedKey must NOT trigger Apply — confirms + // the diff check is in place rather than always firing. + require.NoError(t, r.PatchSettings(settings.Settings{settings.PeerShareEnabledKey: false})) + assert.Equal(t, int32(2), applyCalls.Load(), "PatchSettings without UnboundedKey must not dispatch to unbounded.Apply") +} diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 4eb85539..2ebc88f7 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -200,6 +200,9 @@ func SetEnabled(enable bool) error { // armed gate is also checked inside start, so even a queued // transition stays a no-op after Stop. func Apply() error { + if h := applyHook; h != nil { + h() + } if !Enabled() { manager.stop() return nil @@ -337,6 +340,23 @@ func Stop(ctx context.Context) error { // applies for the duration of the test. var internalStopTimeout = 5 * time.Second +// applyHook is invoked at the top of Apply, before any state check +// or transition. nil in production; backend tests install a counter +// or assertion to verify that PatchSettings actually dispatches the +// UnboundedKey diff to this package. Keep this minimal — exposing +// the manager's internals across packages just for test wiring +// would be a much bigger surface. +var applyHook func() + +// SetApplyHookForTest installs h to be invoked at the start of +// every Apply call. Pass nil to remove. Test-only; production code +// must not call this. The hook fires regardless of the Enabled() +// gate so callers can verify dispatch happened even when no +// transition results. +func SetApplyHookForTest(h func()) { + applyHook = h +} + // stopCtx is the shared implementation for both the public Stop // (disarm=true) and internal manager.stop (disarm=false). Holds // transitionMu for the entire signal+wait so a concurrent start From 6987debeb531dff8218be9d07398e00a29e90caf Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 31 May 2026 17:14:22 -0600 Subject: [PATCH 54/63] unbounded: validate cfg URLs + drain post-cancel callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot findings: 1. shouldStart treated any non-nil UnboundedConfig as runnable. A config refresh carrying an empty or partially-populated cfg block would start broflake with clientcore defaults — pointing at the upstream maintainer's discovery/egress infra rather than the per-environment URLs the server sends. That bypasses the server-supplied endpoint gate and the 'is this user opted in?' contract. Added cfgUsable(cfg) requiring all four URL fields (DiscoverySrv + DiscoveryEndpoint + EgressAddr + EgressEndpoint) non-empty. CTableSize / PTableSize stay optional — the server only sends them when it wants to override defaults. shouldStart now gates on cfgUsable rather than 'cfg != nil'. Tests primeManager and the rest of the suite now use a new testCfg() helper that returns a populated, predicate-passing cfg. Bare &C.UnboundedConfig{} literals were dropped (they'd now fail the runnable check). New TestShouldStart row covers the partial-cfg case. 2. OnConnectionChangeFunc kept emitting after stop signalled cancel — broflake's per-worker connection-change goroutines can fire concurrently with ui.Stop, and the broflake API doesn't promise no-callbacks-after-Stop. Callbacks that landed in this window pushed stale ConnectionEvents onto the event bus after the consumer thought Unbounded was off. Added a ctx.Err() check at the top of the callback closure. ctx is the same one cancelled by stop, so post-cancel callbacks short-circuit before reaching events.Emit. Mirrors peer.go's listenerDraining pattern; broflake doesn't expose an equivalent registration point, so the inline check is the next-best place. Stress-tested 10x under -race -count=1, all clean. Co-Authored-By: Claude Opus 4.7 --- unbounded/unbounded.go | 38 +++++++++++++++++++++- unbounded/unbounded_test.go | 64 ++++++++++++++++++++++++++----------- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 2ebc88f7..bb2a8aad 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -144,7 +144,28 @@ type unboundedManager struct { // shouldStart reports whether all three start conditions hold. Caller // must hold m.mu. func (m *unboundedManager) shouldStart() bool { - return settings.GetBool(settings.UnboundedKey) && m.lastFeatureOn && m.lastCfg != nil + return settings.GetBool(settings.UnboundedKey) && m.lastFeatureOn && cfgUsable(m.lastCfg) +} + +// cfgUsable reports whether the cached UnboundedConfig supplies the +// minimum fields broflake needs to route real consumer traffic: +// discovery (server + endpoint) and egress (address + endpoint). The +// server contract is that all four are required; broflake's +// clientcore defaults exist for unit-test convenience and point at +// the upstream maintainer's infra — running them in a Lantern build +// would bypass the server's per-environment endpoint selection and +// the "is this user opted in?" feature-flag gate, so a partially- +// populated config is treated as "not yet ready to start" rather +// than "fall back to defaults". +// +// CTableSize / PTableSize are not required; defaults are reasonable +// and the server sends them only when it wants to override. +func cfgUsable(cfg *C.UnboundedConfig) bool { + if cfg == nil { + return false + } + return cfg.DiscoverySrv != "" && cfg.DiscoveryEndpoint != "" && + cfg.EgressAddr != "" && cfg.EgressEndpoint != "" } // cfgEqual reports whether two UnboundedConfig pointers refer to @@ -458,7 +479,22 @@ func (m *unboundedManager) start() { // Wire the broflake connection callback into the radiance event // bus so the Flutter globe (and any future abuse aggregation) // sees consumer connect/disconnect. + // + // Cancellation drain: broflake's per-worker connection-change + // goroutines can fire callbacks concurrently with ui.Stop, and + // the broflake API doesn't promise no-callbacks-after-Stop. + // Check ctx.Err() at the top so callbacks delivered after + // stop signals cancel — but before broflake's internal + // teardown drained — short-circuit instead of pushing a stale + // connection event onto the bus after the consumer thinks + // Unbounded is off. Mirrors the peer.go listenerDraining + // pattern (peer wraps its peerconn listener; broflake doesn't + // expose an equivalent registration point, so the ctx check + // inside the closure is the next-best place). bfOpt.OnConnectionChangeFunc = func(state int, workerIdx int, addr net.IP) { + if ctx.Err() != nil { + return + } addrStr := "" if addr != nil { addrStr = addr.String() diff --git a/unbounded/unbounded_test.go b/unbounded/unbounded_test.go index 54a473f8..9b0ff737 100644 --- a/unbounded/unbounded_test.go +++ b/unbounded/unbounded_test.go @@ -101,14 +101,17 @@ func resetManager(t *testing.T, build func(*clientcore.BroflakeOptions, *clientc // primeManager seeds the predicate fields so a direct manager.start() // in a test will satisfy the shouldStart() check inside the lock: the // UnboundedKey setting goes true, manager.lastFeatureOn = true, and -// manager.lastCfg = the supplied cfg (or a zero-value -// UnboundedConfig if nil). Tests that exercise the start path -// directly call this once after resetManager. +// manager.lastCfg = the supplied cfg (or testCfg if nil). Tests that +// exercise the start path directly call this once after resetManager. +// +// The default test cfg populates all four required URL fields so +// cfgUsable passes — a zero-value UnboundedConfig would fail the +// "all fields supplied" gate and start() would bail. func primeManager(t *testing.T, cfg *C.UnboundedConfig) { t.Helper() require.NoError(t, settings.Set(settings.UnboundedKey, true)) if cfg == nil { - cfg = &C.UnboundedConfig{} + cfg = testCfg() } manager.mu.Lock() manager.lastFeatureOn = true @@ -116,6 +119,18 @@ func primeManager(t *testing.T, cfg *C.UnboundedConfig) { manager.mu.Unlock() } +// testCfg returns a UnboundedConfig that passes cfgUsable. Use this +// instead of a bare `&C.UnboundedConfig{}` literal in tests; the +// zero-value literal would now fail the runnable predicate. +func testCfg() *C.UnboundedConfig { + return &C.UnboundedConfig{ + DiscoverySrv: "https://discovery.test.example", + DiscoveryEndpoint: "/v1/disco", + EgressAddr: "https://egress.test.example", + EgressEndpoint: "/v1/egress", + } +} + // or the deadline expires. The start goroutine sets m.cancel under // m.mu before kicking off the worker, so this is a sufficient signal // that a transition has been requested — note that for "true" the @@ -164,10 +179,19 @@ func TestShouldStart(t *testing.T) { }{ {"all off", false, false, nil, false}, {"toggle only", true, false, nil, false}, - {"feature+cfg, no toggle", false, true, &C.UnboundedConfig{}, false}, + {"feature+cfg, no toggle", false, true, testCfg(), false}, {"toggle+feature, no cfg", true, true, nil, false}, - {"toggle+cfg, no feature", true, false, &C.UnboundedConfig{}, false}, - {"all three", true, true, &C.UnboundedConfig{}, true}, + {"toggle+cfg, no feature", true, false, testCfg(), false}, + {"all three", true, true, testCfg(), true}, + // Partial cfg (missing required URLs) treated as not-yet-ready + // — broflake's client defaults would otherwise route real + // traffic through upstream-maintainer infra, bypassing the + // server's per-environment endpoint selection. + {"partial cfg, no egress", true, true, + &C.UnboundedConfig{ + DiscoverySrv: "https://d.example", + DiscoveryEndpoint: "/v1/disco", + }, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -193,7 +217,7 @@ func TestApply_DisabledIsNoop(t *testing.T) { manager.mu.Lock() manager.lastFeatureOn = true - manager.lastCfg = &C.UnboundedConfig{} + manager.lastCfg = testCfg() manager.mu.Unlock() require.NoError(t, Apply()) @@ -215,7 +239,7 @@ func TestApply_StartsWhenAllConditionsHold(t *testing.T) { require.NoError(t, settings.Set(settings.UnboundedKey, true)) manager.mu.Lock() manager.lastFeatureOn = true - manager.lastCfg = &C.UnboundedConfig{} + manager.lastCfg = testCfg() manager.mu.Unlock() require.NoError(t, Apply()) @@ -246,7 +270,7 @@ func TestStop_WaitsForWorker(t *testing.T) { require.NoError(t, settings.Set(settings.UnboundedKey, true)) manager.mu.Lock() manager.lastFeatureOn = true - manager.lastCfg = &C.UnboundedConfig{} + manager.lastCfg = testCfg() manager.mu.Unlock() require.NoError(t, Apply()) waitForRunning(t, manager, true, 1*time.Second) @@ -370,7 +394,7 @@ func TestInitSubscription_SeedsCachedConfig(t *testing.T) { require.NoError(t, settings.Set(settings.UnboundedKey, true)) cfg := &config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, - Unbounded: &C.UnboundedConfig{}, + Unbounded: testCfg(), } InitSubscription(cfg) @@ -401,7 +425,7 @@ func TestInitSubscription_FutureEventStillFires(t *testing.T) { // Fire a NewConfigEvent with all three conditions satisfied. cfg := config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, - Unbounded: &C.UnboundedConfig{}, + Unbounded: testCfg(), } events.Emit(config.NewConfigEvent{New: &cfg}) waitForRunning(t, manager, true, 1*time.Second) @@ -430,10 +454,14 @@ func TestApplyConfig_RestartsOnParamChange(t *testing.T) { require.NoError(t, settings.Set(settings.UnboundedKey, true)) + cfgA := testCfg() + cfgB := testCfg() + cfgB.DiscoverySrv = "https://b.example/" + // First config — bring up widget #1. applyConfig(config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, - Unbounded: &C.UnboundedConfig{DiscoverySrv: "https://a.example/"}, + Unbounded: cfgA, }) waitForRunning(t, manager, true, 1*time.Second) waitForCount(t, &starts, 1, 1*time.Second) @@ -441,7 +469,7 @@ func TestApplyConfig_RestartsOnParamChange(t *testing.T) { // Same config — no restart. applyConfig(config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, - Unbounded: &C.UnboundedConfig{DiscoverySrv: "https://a.example/"}, + Unbounded: testCfg(), // value-equal to cfgA }) time.Sleep(50 * time.Millisecond) assert.Equal(t, int32(1), starts.Load(), "identical config must not restart") @@ -449,7 +477,7 @@ func TestApplyConfig_RestartsOnParamChange(t *testing.T) { // Changed config — restart with new params. applyConfig(config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, - Unbounded: &C.UnboundedConfig{DiscoverySrv: "https://b.example/"}, + Unbounded: cfgB, }) waitForCount(t, &starts, 2, 2*time.Second) @@ -495,7 +523,7 @@ func TestStopDisarmsManager(t *testing.T) { require.NoError(t, Apply()) applyConfig(config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, - Unbounded: &C.UnboundedConfig{}, + Unbounded: testCfg(), }) manager.start() time.Sleep(50 * time.Millisecond) @@ -505,7 +533,7 @@ func TestStopDisarmsManager(t *testing.T) { initOnce = sync.Once{} // simulate re-arm path; InitSubscription's once guards re-subscription InitSubscription(&config.Config{ Features: map[string]bool{C.UNBOUNDED: true}, - Unbounded: &C.UnboundedConfig{}, + Unbounded: testCfg(), }) waitForCount(t, &starts, 2, 1*time.Second) @@ -590,7 +618,7 @@ func TestStopCtx_TimesOut(t *testing.T) { require.NoError(t, settings.Set(settings.UnboundedKey, true)) manager.mu.Lock() manager.lastFeatureOn = true - manager.lastCfg = &C.UnboundedConfig{} + manager.lastCfg = testCfg() manager.mu.Unlock() require.NoError(t, Apply()) waitForRunning(t, manager, true, 1*time.Second) From 242bca994baf6283c8f6dadad672ad82001e3704 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 31 May 2026 17:27:35 -0600 Subject: [PATCH 55/63] unbounded: drop code-location refs from comments Three small comment cleanups per AGENTS.md (no code-location references in comments): - unbounded.go: drop 'peer.go listenerDraining' phrasing from the cancellation-drain comment; describe the broflake-side constraint directly (no registration point to disarm, so the inline ctx check is the next-best place). - backend/radiance_test.go: drop 'Companion to TestPatchSettings_ PeerShareDispatches' framing from TestPatchSettings_ UnboundedDispatches docstring; state the invariant directly. - unbounded/test_helpers_test.go: drop 'Used by TestStartDuringStop' framing from countingWidget docstring; describe the helper's role (callback lets test observe shutdown ordering) without naming a consumer. No behavior changes; tests unchanged + still clean. Co-Authored-By: Claude Opus 4.7 --- backend/radiance_test.go | 10 +++++----- unbounded/test_helpers_test.go | 6 ++++-- unbounded/unbounded.go | 15 +++++++-------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/backend/radiance_test.go b/backend/radiance_test.go index 10fe1bec..e885fc1b 100644 --- a/backend/radiance_test.go +++ b/backend/radiance_test.go @@ -219,11 +219,11 @@ func TestPatchSettings_PeerShareDispatches(t *testing.T) { } // Verify PatchSettings routes UnboundedKey to unbounded.Apply via the -// SetApplyHookForTest hook. Companion to TestPatchSettings_PeerShareDispatches — -// a typo on the diff key or a removal of the Apply call would silently -// leave the Unbounded toggle persisted but inert. The hook fires -// regardless of the Enabled() gate inside Apply, so this catches the -// dispatch even though we don't prime the rest of the manager state. +// SetApplyHookForTest hook. A typo on the diff key or a removal of +// the Apply call would silently leave the Unbounded toggle persisted +// but inert. The hook fires regardless of the Enabled() gate inside +// Apply, so this catches the dispatch even though we don't prime the +// rest of the manager state. func TestPatchSettings_UnboundedDispatches(t *testing.T) { r := newPeerTestBackend(t, &fakePeerController{}) diff --git a/unbounded/test_helpers_test.go b/unbounded/test_helpers_test.go index 20262bc6..c124a5cc 100644 --- a/unbounded/test_helpers_test.go +++ b/unbounded/test_helpers_test.go @@ -1,8 +1,10 @@ package unbounded // countingWidget is a fakeWidget variant whose Stop runs a caller- -// supplied callback before returning. Used by TestStartDuringStop -// to decrement the live-widget counter under the test's own gate. +// supplied callback before returning. The callback lets the test +// observe shutdown ordering — typically by decrementing a live- +// widget counter so the test can assert that at most one widget is +// alive across a stop/start transition. type countingWidget struct { onStop func() } diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index bb2a8aad..2e12ef12 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -483,14 +483,13 @@ func (m *unboundedManager) start() { // Cancellation drain: broflake's per-worker connection-change // goroutines can fire callbacks concurrently with ui.Stop, and // the broflake API doesn't promise no-callbacks-after-Stop. - // Check ctx.Err() at the top so callbacks delivered after - // stop signals cancel — but before broflake's internal - // teardown drained — short-circuit instead of pushing a stale - // connection event onto the bus after the consumer thinks - // Unbounded is off. Mirrors the peer.go listenerDraining - // pattern (peer wraps its peerconn listener; broflake doesn't - // expose an equivalent registration point, so the ctx check - // inside the closure is the next-best place). + // Check ctx.Err() at the top so callbacks delivered after stop + // signals cancel — but before broflake's internal teardown + // drained — short-circuit instead of pushing a stale connection + // event onto the bus after the consumer thinks Unbounded is + // off. broflake exposes no registration point we could + // disarm directly (the callback IS the registration), so the + // inline ctx check is the next-best place. bfOpt.OnConnectionChangeFunc = func(state int, workerIdx int, addr net.IP) { if ctx.Err() != nil { return From 0a9e276a9e00775df9734cf03dad67664fa99ac9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 6 Jun 2026 18:00:56 -0600 Subject: [PATCH 56/63] unbounded: keep consumer Source on close so globe + counter balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit broflake's OnConnectionChange delivers the consumer addr on accept but a nil addr on close (the WebRTC session is already torn down, so the remote IP is gone). The close ConnectionEvent therefore carried an empty Source, and downstream consumers couldn't pair it with its accept: the Flutter globe matches the arc to remove by source IP, and the "people helped" counter decrements the same way. So closes were dropped — arcs orphaned and accumulated, and the live counter only ever grew (it equalled the lifetime total). Track each consumer slot's addr on accept (connSources, keyed by broflake's workerIdx — stable across a connection's accept->close) and restore it on close, so every -1 carries the same Source its +1 did. No event-shape change; downstream consumers already key off Source. Co-Authored-By: Claude Opus 4.8 (1M context) --- unbounded/conn_sources_test.go | 56 ++++++++++++++++++++++++++++++++++ unbounded/unbounded.go | 56 ++++++++++++++++++++++++++++++++-- unbounded/unbounded_test.go | 16 ++++++---- 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 unbounded/conn_sources_test.go diff --git a/unbounded/conn_sources_test.go b/unbounded/conn_sources_test.go new file mode 100644 index 00000000..00e08071 --- /dev/null +++ b/unbounded/conn_sources_test.go @@ -0,0 +1,56 @@ +package unbounded + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestConnSources_resolve pins the accept→close source backfill. broflake +// delivers the consumer addr on accept but a nil (empty) addr on close, so +// resolve must restore the accept's addr onto the close — otherwise the close +// event carries an empty Source and downstream consumers (the Flutter globe +// arc + the "people helped" counter) can't match it to the accept, so the +// arc orphans and the counter never decrements. +func TestConnSources_resolve(t *testing.T) { + c := newConnSources() + + // Accept echoes its own addr and remembers it for the slot. + assert.Equal(t, "1.2.3.4", c.resolve(1, 7, "1.2.3.4"), + "accept returns its own source") + + // Close arrives with an empty addr (broflake's nil) — restore the + // accept's addr so the -1 can be matched to its +1. + assert.Equal(t, "1.2.3.4", c.resolve(-1, 7, ""), + "close restores the accept's source") + + // The slot was freed; a stale/duplicate close has nothing to restore. + assert.Equal(t, "", c.resolve(-1, 7, ""), + "close after the slot is freed restores nothing") + + // Slots are tracked independently. + c.resolve(1, 8, "5.6.7.8") + c.resolve(1, 9, "1.1.1.1") + assert.Equal(t, "5.6.7.8", c.resolve(-1, 8, ""), "slot 8 restores 8's addr") + assert.Equal(t, "1.1.1.1", c.resolve(-1, 9, ""), "slot 9 unaffected by slot 8") + + // An accept with no addr (broflake couldn't surface the consumer IP) + // stays empty through close — neither end is counted, which is correct: + // with no source there's nothing to match or draw. + assert.Equal(t, "", c.resolve(1, 10, ""), "accept with empty addr stays empty") + assert.Equal(t, "", c.resolve(-1, 10, ""), "its close stays empty too") + + // A close that already carries a real addr is passed through unchanged + // (don't clobber a good value) and still frees the slot. + c.resolve(1, 11, "9.9.9.9") + assert.Equal(t, "9.9.9.9", c.resolve(-1, 11, "9.9.9.9"), + "close with a real addr is passed through") + assert.Equal(t, "", c.resolve(-1, 11, ""), "slot 11 freed after its close") + + // Slot reuse: broflake recycles a workerIdx; a fresh accept overwrites + // the prior addr even without an intervening close. + c.resolve(1, 12, "2.2.2.2") + c.resolve(1, 12, "3.3.3.3") + assert.Equal(t, "3.3.3.3", c.resolve(-1, 12, ""), + "reused slot restores the latest accept's addr") +} diff --git a/unbounded/unbounded.go b/unbounded/unbounded.go index 2e12ef12..3d7c5d96 100644 --- a/unbounded/unbounded.go +++ b/unbounded/unbounded.go @@ -43,9 +43,9 @@ import ( // being routed through this widget proxy) connects or disconnects via // the broflake mesh. // -// State +1 on accept, -1 on close -// Source consumer's IP if broflake exposes it, otherwise empty -// Timestamp emit time in Unix milliseconds +// State +1 on accept, -1 on close +// Source consumer's IP if broflake exposes it, otherwise empty +// Timestamp emit time in Unix milliseconds // // JSON shape is identical to peer.ConnectionEvent so a consumer // reading both SSE streams can deserialize each frame with the @@ -65,6 +65,45 @@ type ConnectionEvent struct { Timestamp int64 `json:"timestamp"` } +// connSources tracks the source addr of each live consumer slot so a close +// event — which broflake delivers with a nil addr — can be re-tagged with the +// addr its accept carried. Keyed by broflake's workerIdx (the consumer slot), +// stable across a single connection's accept→close. Without this, a close +// carries an empty Source, downstream consumers (the Flutter globe + helped +// counter) can't match it to the accept, and the connection's arc/count leaks. +// Concurrency-safe: broflake fires connection-change callbacks from per-worker +// goroutines. +type connSources struct { + mu sync.Mutex + addrs map[int]string +} + +func newConnSources() *connSources { + return &connSources{addrs: make(map[int]string)} +} + +// resolve records the addr on accept (state > 0) or restores it on close +// (state < 0, where broflake's addr is nil), returning the Source the +// ConnectionEvent should carry. An accept with an empty addr is left +// untracked, so its close stays empty too — neither is counted, which is the +// right behavior when broflake can't surface the consumer IP at all. +func (c *connSources) resolve(state, workerIdx int, addrStr string) string { + c.mu.Lock() + defer c.mu.Unlock() + switch { + case state > 0: + if addrStr != "" { + c.addrs[workerIdx] = addrStr + } + case state < 0: + if addrStr == "" { + addrStr = c.addrs[workerIdx] + } + delete(c.addrs, workerIdx) + } + return addrStr +} + var manager = &unboundedManager{} // widget is the minimum interface the manager needs from a running @@ -490,6 +529,16 @@ func (m *unboundedManager) start() { // off. broflake exposes no registration point we could // disarm directly (the callback IS the registration), so the // inline ctx check is the next-best place. + // broflake hands us the consumer's addr on accept but a nil addr on + // close (the WebRTC session is already torn down, so the remote IP is + // gone). Consumers of ConnectionEvent identify a connection by its + // Source: the Flutter globe matches a close to the arc it should + // remove by source IP, and decrements its "people helped" counter the + // same way. A close with an empty Source can't be matched, so the arc + // orphans and the counter never comes back down. sources remembers + // each slot's addr on accept and restores it on close so every -1 + // carries the same Source its +1 did. + sources := newConnSources() bfOpt.OnConnectionChangeFunc = func(state int, workerIdx int, addr net.IP) { if ctx.Err() != nil { return @@ -498,6 +547,7 @@ func (m *unboundedManager) start() { if addr != nil { addrStr = addr.String() } + addrStr = sources.resolve(state, workerIdx, addrStr) slog.Debug("Unbounded: consumer connection change", "state", state, "workerIdx", workerIdx, "source", addrStr) events.Emit(ConnectionEvent{ diff --git a/unbounded/unbounded_test.go b/unbounded/unbounded_test.go index 9b0ff737..9f7fd09b 100644 --- a/unbounded/unbounded_test.go +++ b/unbounded/unbounded_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - C "github.com/getlantern/common" "github.com/getlantern/broflake/clientcore" + C "github.com/getlantern/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -635,10 +635,13 @@ func TestStopCtx_TimesOut(t *testing.T) { // TestConnectionEventBridge pins the observable API this package // adds: broflake's OnConnectionChangeFunc callback must translate // (state, workerIdx, addr) into a ConnectionEvent with the matching -// State, the addr.String() Source (empty when addr is nil), and a -// freshly-stamped Timestamp. Capture the callback that start -// installs on bfOpt via a fake newWidget, invoke it with both nil -// and non-nil addrs, then assert the events arriving via +// State, the consumer Source, and a freshly-stamped Timestamp. +// broflake hands the callback a nil addr on close, so the close's +// Source is backfilled from the addr its accept carried (see +// connSources) — otherwise downstream consumers (the Flutter globe + +// helped counter) couldn't match the close to its accept. Capture the +// callback that start installs on bfOpt via a fake newWidget, invoke +// accept-then-close on one slot, then assert the events arriving via // events.Subscribe. func TestConnectionEventBridge(t *testing.T) { var captured atomic.Pointer[clientcore.ConnectionChangeFunc] @@ -690,7 +693,8 @@ func TestConnectionEventBridge(t *testing.T) { require.Contains(t, byState, 1, "expected an accept event (State=+1)") require.Contains(t, byState, -1, "expected a close event (State=-1)") assert.Equal(t, "198.51.100.42", byState[1].Source, "accept Source") - assert.Equal(t, "", byState[-1].Source, "close Source (nil addr -> empty string)") + assert.Equal(t, "198.51.100.42", byState[-1].Source, + "close Source is backfilled from the accept (broflake delivers a nil addr on close)") for state, evt := range byState { assert.GreaterOrEqual(t, evt.Timestamp, before, "State=%d Timestamp not before emit", state) assert.LessOrEqual(t, evt.Timestamp, after, "State=%d Timestamp not after emit", state) From 03dc83f626ffed0eb10a202de5eaaed5e3f6a772 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 5 Aug 2026 15:13:15 -0600 Subject: [PATCH 57/63] gofmt peer-share files carried over from the branch stack Formatting only; gofmt's doc-comment list indentation had not been applied to these files on the source branches. --- common/settings/settings.go | 2 +- portforward/portforward.go | 12 +++++++++--- portforward/portforward_test.go | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/common/settings/settings.go b/common/settings/settings.go index 9734804b..d631a6cc 100644 --- a/common/settings/settings.go +++ b/common/settings/settings.go @@ -63,7 +63,7 @@ const ( // Advanced setting in the Share My Connection UI for users on // networks where UPnP is disabled or unavailable. PeerManualPortKey _key = "peer_manual_port" // int (0 = unset; 1..65535 = manual port) - SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored + SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation] diff --git a/portforward/portforward.go b/portforward/portforward.go index 3ae2ff93..45c788ac 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -371,7 +371,9 @@ func discoverIGDv1(ctx context.Context) (igdClient, error) { // IGDv1 and IGDv2's generated clients have slightly different method // signatures, so wrappers normalize them to a single igdClient interface. -type wanIPv2Wrapper struct{ c *internetgateway2.WANIPConnection2 } +type wanIPv2Wrapper struct { + c *internetgateway2.WANIPConnection2 +} func (w wanIPv2Wrapper) AddPortMapping(remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error { return w.c.AddPortMapping(remoteHost, externalPort, protocol, internalPort, internalClient, enabled, description, leaseDuration) @@ -383,7 +385,9 @@ func (w wanIPv2Wrapper) GetExternalIPAddress() (string, error) { return w.c.GetExternalIPAddress() } -type wanIPv1Wrapper struct{ c *internetgateway1.WANIPConnection1 } +type wanIPv1Wrapper struct { + c *internetgateway1.WANIPConnection1 +} func (w wanIPv1Wrapper) AddPortMapping(remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error { return w.c.AddPortMapping(remoteHost, externalPort, protocol, internalPort, internalClient, enabled, description, leaseDuration) @@ -395,7 +399,9 @@ func (w wanIPv1Wrapper) GetExternalIPAddress() (string, error) { return w.c.GetExternalIPAddress() } -type wanPPPv1Wrapper struct{ c *internetgateway1.WANPPPConnection1 } +type wanPPPv1Wrapper struct { + c *internetgateway1.WANPPPConnection1 +} func (w wanPPPv1Wrapper) AddPortMapping(remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error { return w.c.AddPortMapping(remoteHost, externalPort, protocol, internalPort, internalClient, enabled, description, leaseDuration) diff --git a/portforward/portforward_test.go b/portforward/portforward_test.go index c52982b5..c7b7c22d 100644 --- a/portforward/portforward_test.go +++ b/portforward/portforward_test.go @@ -26,7 +26,7 @@ type fakeIGD struct { } type mappingArgs struct { - externalPort, internalPort uint16 + externalPort, internalPort uint16 internalClient, description string leaseDuration uint32 } @@ -232,7 +232,7 @@ func (emptyExtIPClient) AddPortMapping(string, uint16, string, uint16, string, b return nil } func (emptyExtIPClient) DeletePortMapping(string, uint16, string) error { return nil } -func (emptyExtIPClient) GetExternalIPAddress() (string, error) { return "", nil } +func (emptyExtIPClient) GetExternalIPAddress() (string, error) { return "", nil } func TestForwarder_ExternalIP_PropagatesError(t *testing.T) { c := &fakeIGD{extIPErr: errors.New("upstream timeout")} From 89bad60fac629188bcfca1e4bece0242b8d3c73a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 5 Aug 2026 15:46:18 -0600 Subject: [PATCH 58/63] peer: route the peer box's sing-box logger into radiance's slog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The peer runs a second sing-box beside the main tunnel's. Without its own registered log factory it keeps sing-box's stderr-only default, so this box's router and dial errors never reach lantern.log — the signal that explains why a peer-share verify failed. Registering the factory required fixing how the box context is built. box.BaseContext() constructs a fresh service registry on every call, and the previous wrapper called it on every Value miss, so each lookup resolved to a different registry object. Reads were unaffected — every fresh base carries the same protocol registrations — but a service.MustRegister through that wrapper wrote into a registry that was discarded before libbox read it, making the registration a silent no-op. The base context is now captured once per box, the factory is registered against it, and the wrapper resolves values from that single instance while still taking cancellation from the caller. Four tests pin the properties involved. The two that encode the bug — factory retrievable, registry stable across lookups — fail against the old per-lookup rebuild; the cancellation and inbound-registry tests pass either way, since those properties were never broken. --- peer/peer.go | 63 ++++++++++++++++++++++++++++------------------- peer/peer_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index c447b45a..54d0a9b2 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -13,8 +13,11 @@ import ( "time" "github.com/sagernet/sing-box/experimental/libbox" + sblog "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/service" box "github.com/getlantern/lantern-box" + lblog "github.com/getlantern/lantern-box/log" "github.com/getlantern/lantern-box/tracker/peerconn" "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/common/settings" @@ -1009,43 +1012,51 @@ func pickInternalPort() uint16 { // inbound is just an HTTPS server bound to a TCP port; sing-box's default // network stack handles it. // -// box.BaseContext registers the lantern-box protocol fields registries -// (samizdat, reflex, etc.) into the ctx so libbox can decode the -// inbounds[0].type="samizdat" stanza coming back from /peer/register. -// Without it the user's ctx is missing InboundOptionsRegistry and -// libbox returns "missing inbound fields registry in context" — the -// failure mode is silent in CI because the integration tests stub -// BuildBoxService entirely; only TestDefaultBuildBoxService_DecodesSamizdatInbound -// exercises the real decode path. -// -// We wrap so libbox sees the caller's Deadline/Done (so a Stop-induced -// ctx cancel propagates to box internals) AND can still resolve the -// registry values from box.BaseContext via Value lookups. -// -// Lives in the same process as the user's main VPN tunnel, which has -// already invoked libbox.Setup at process start. The registries set -// here are scoped to this peer's box instance via context values, so -// the two coexist without stomping on each other. +// The registries newPeerBoxContext supplies are what let libbox decode the +// inbounds[0].type="samizdat" stanza from /peer/register; without them it +// fails with "missing inbound fields registry in context". They are scoped +// to this box instance, so the peer and the main tunnel coexist without +// stomping on each other. func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) { - bs, err := libbox.NewServiceWithContext(boxRegistryCtx{ctx}, options, nil) + bs, err := libbox.NewServiceWithContext(newPeerBoxContext(ctx), options, nil) if err != nil { return nil, fmt.Errorf("libbox.NewServiceWithContext: %w", err) } return bs, nil } -// boxRegistryCtx is a context wrapper that delegates Value() lookups to -// box.BaseContext() (where lantern-box's protocol registries live) while -// keeping the caller's Deadline/Done/Err for cancellation. Without this, -// passing box.BaseContext() directly to libbox would discard the -// caller's runCtx, leaving libbox internals running past Stop. -type boxRegistryCtx struct { +// newPeerBoxContext assembles the context for one peer box: cancellation +// from ctx, lantern-box's protocol registries and this box's log factory +// from a single captured base context. +// +// The base must be captured exactly once. box.BaseContext() builds a new +// service registry on every call, and service.MustRegister mutates +// whichever registry the context hands back, so registering through a +// wrapper that rebuilds the base per lookup writes into a registry that +// is discarded before libbox ever reads it — the registration silently +// does nothing. +func newPeerBoxContext(ctx context.Context) context.Context { + base := box.BaseContext() + // The peer runs a second box beside the main tunnel's. Absent its own + // factory it keeps sing-box's stderr-only default, so this box's + // router and dial errors never reach lantern.log — the signal that + // explains why a peer-share verify failed. Mirrors the main tunnel's + // registration. + service.MustRegister[sblog.Factory](base, lblog.NewFactory(slog.Default().Handler())) + return peerBoxContext{Context: ctx, base: base} +} + +// peerBoxContext resolves Deadline/Done/Err from the embedded caller +// context so a Stop-induced cancel propagates into box internals, and +// every other value from base. +type peerBoxContext struct { context.Context + base context.Context } -func (c boxRegistryCtx) Value(key any) any { +func (c peerBoxContext) Value(key any) any { if v := c.Context.Value(key); v != nil { return v } - return box.BaseContext().Value(key) + return c.base.Value(key) } diff --git a/peer/peer_test.go b/peer/peer_test.go index 4557c522..7361e83a 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -12,6 +12,9 @@ import ( "testing" "time" + sblog "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1064,3 +1067,63 @@ func TestAPI_ForwardsCommonHeaders(t *testing.T) { assert.NotEmpty(t, c.appName, "%s must carry %s", path, common.AppNameHeader) } } + +// The peer box's log factory has to survive the trip into libbox, and the +// bug that made it not survive was invisible: box.BaseContext() mints a +// fresh service registry per call, so a wrapper that rebuilt the base on +// every Value lookup handed out a different registry each time. Reads kept +// working (each fresh base carries the same protocol registrations), which +// is why only a registration could expose it. These four tests pin the +// properties that make the registration land. + +// A registration made against the context libbox receives must be +// retrievable from that same context. +func TestPeerBoxContext_LogFactoryIsRetrievable(t *testing.T) { + boxCtx := newPeerBoxContext(context.Background()) + + got := service.FromContext[sblog.Factory](boxCtx) + require.NotNil(t, got, "the registered sing-box log factory must be readable "+ + "from the context handed to libbox, or this box logs only to stderr") +} + +// Repeated registry lookups must return the same object. This is the +// invariant the old wrapper broke: two lookups, two registries, so a write +// through one was never seen through the other. +func TestPeerBoxContext_RegistryIsStableAcrossLookups(t *testing.T) { + boxCtx := newPeerBoxContext(context.Background()) + + first := service.RegistryFromContext(boxCtx) + second := service.RegistryFromContext(boxCtx) + require.NotNil(t, first) + assert.True(t, first == second, + "every lookup must resolve to one registry; a per-lookup rebuild makes "+ + "service.MustRegister write into an object that is immediately discarded") +} + +// Cancellation still comes from the caller, so a Stop-induced cancel reaches +// box internals rather than being swallowed by the base context. +func TestPeerBoxContext_InheritsCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + boxCtx := newPeerBoxContext(ctx) + require.NoError(t, boxCtx.Err()) + + cancel() + + select { + case <-boxCtx.Done(): + case <-time.After(2 * time.Second): + t.Fatal("peer box context did not observe the caller's cancel") + } + assert.ErrorIs(t, boxCtx.Err(), context.Canceled) +} + +// The lantern-box protocol registries must still resolve through the +// wrapper. This is the registry libbox reports as "missing inbound fields +// registry in context" when it is absent, which is what would break +// decoding the samizdat inbound from /peer/register. +func TestPeerBoxContext_StillResolvesInboundRegistry(t *testing.T) { + boxCtx := newPeerBoxContext(context.Background()) + + assert.NotNil(t, service.FromContext[option.InboundOptionsRegistry](boxCtx), + "registering the log factory must not displace the protocol registries") +} From bbb950816787eef574225419e134083bdfa8f530 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 5 Aug 2026 17:13:20 -0600 Subject: [PATCH 59/63] deps: pin lantern-box to the released v0.0.109 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fisk/peerconn-on-main staging pseudo-version now that lantern-box #255 and #256 have merged. v0.0.109 is the auto-tagged release containing peerconn.Event, which peer.go's SetListener callback requires — v0.0.108 predates it and would not compile here. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dec6b3b4..8befc769 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260722204513-8c1f8acfa715 github.com/getlantern/keepcurrent v0.0.0-20260616120552-f204338b01a3 github.com/getlantern/kindling v0.0.0-20260727211028-573c1ef64464 - github.com/getlantern/lantern-box v0.0.108-0.20260805204936-b8cec7fadd27 + github.com/getlantern/lantern-box v0.0.109 github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb diff --git a/go.sum b/go.sum index 947567c5..33bf5d70 100644 --- a/go.sum +++ b/go.sum @@ -246,8 +246,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260616120552-f204338b01a3 h1:YPBbuyvd github.com/getlantern/keepcurrent v0.0.0-20260616120552-f204338b01a3/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260727211028-573c1ef64464 h1:vZtMtKDsGO0ccBr+aQRRDKgcwFTg4psy13HtMttuBVQ= github.com/getlantern/kindling v0.0.0-20260727211028-573c1ef64464/go.mod h1:ZEPFiB6rH6MtMN1O7b5gE+LJpCsRFzAn4u8lbIxHh3w= -github.com/getlantern/lantern-box v0.0.108-0.20260805204936-b8cec7fadd27 h1:21c/r8gqTz2LMKpZyq/MD74V7asQZVNt13pkwuOTFNQ= -github.com/getlantern/lantern-box v0.0.108-0.20260805204936-b8cec7fadd27/go.mod h1:HHdmZsGwkiaweBycCYv1Jolk3jkrXbb/R5UUXtY2n3o= +github.com/getlantern/lantern-box v0.0.109 h1:21ayhwRjqP2jGK6+LVZbDvLb43Z4Kt+VjufgtGGI2XY= +github.com/getlantern/lantern-box v0.0.109/go.mod h1:HHdmZsGwkiaweBycCYv1Jolk3jkrXbb/R5UUXtY2n3o= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395 h1:grfGavAUp2E9w9ZoJuM3FyWyQ0sCJ64V4ZMKtZKRqTc= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= From 681f5ebfec499e11b41cc8f1c1dd5706d7633282 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 5 Aug 2026 17:31:23 -0600 Subject: [PATCH 60/63] Address Copilot and CodeRabbit review on #589 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit events: emitDebugLogger was a plain global read by Emit from arbitrary goroutines and written by SetEmitDebugLogger. Held in an atomic.Pointer now; the new events test reproduces the race and fails under -race without the change. portforward: a renewal already past its teardown pre-check could re-add the router mapping after UnmapPort deleted it, leaving an inbound forward to the user's host that nothing removes — permanent on routers that ignore the requested lease. UnmapPort marks teardown under the lock and the renewal re-checks afterwards, deleting what it re-added. The renewal no longer goes through runWithCtx: that returns on cancellation while its goroutine keeps running, so the compensating delete could otherwise fire before the call it exists to undo. A wedged gateway is bounded by renewCallTimeout and treated as "may have landed". portforward: MapPort returns early on an already-cancelled ctx rather than enumerating interfaces first. backend: construction degrades to a nil peerClient instead of failing, restoring the invariant documented on NewLocalBackend that only common.Init is fatal — a peer-client failure must not cost the user issue reporting. PeerStatus and applyPeerShare guard nil accordingly, with applyPeerShare rolling the setting back so a persisted "on" can't outlive an unavailable client. backend: PatchSettings applies both the VPN and peer-share handlers and joins their errors. settings.Patch has already persisted the whole diff by then, so returning early on a VPN restart failure left PeerShareEnabledKey persisted but unapplied. peer: isUnconditionalReject tolerates an explicit "type":"default" on inlined rules. launch_cfg is authored server-side rather than round-tripped through sing-box's marshaller, so the discriminator can legitimately appear, and treating it as an extra constraint made the peer refuse a config that does reject unconditionally. Non-default types are still rejected — they carry their own matching fields. The go.mod staging-pin comment was resolved separately by bbb9508. --- backend/peer_share.go | 21 +++++++- backend/radiance.go | 18 ++++--- backend/radiance_test.go | 37 +++++++++++++ events/events.go | 22 ++++---- events/events_test.go | 95 +++++++++++++++++++++++++++++++++ peer/validate.go | 14 ++++- peer/validate_test.go | 32 +++++++++++ portforward/portforward.go | 90 ++++++++++++++++++++++++++++--- portforward/portforward_test.go | 63 ++++++++++++++++++++++ 9 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 events/events_test.go diff --git a/backend/peer_share.go b/backend/peer_share.go index b488a106..ebbac7af 100644 --- a/backend/peer_share.go +++ b/backend/peer_share.go @@ -2,6 +2,7 @@ package backend import ( "context" + "errors" "fmt" "log/slog" "time" @@ -49,6 +50,19 @@ func newPeerClient(platformDeviceID string) (*peer.Client, error) { // sequence could see the second call's "already active" rollback racing the // third call's Stop. func (r *LocalBackend) applyPeerShare(enabled bool) error { + // Construction degrades to a nil client rather than failing (the backend + // must always come up so a user can report an issue), so the toggle + // reports the outage instead of panicking. Roll the setting back, or a + // persisted "on" would survive with nothing behind it. + if r.peerClient == nil { + if enabled { + if rbErr := settings.Patch(settings.Settings{settings.PeerShareEnabledKey: false}); rbErr != nil { + slog.Error("peer share rollback failed with no peer client", "error", rbErr) + } + return errors.New("peer share unavailable: peer client failed to initialize") + } + return nil + } r.peerToggleMu.Lock() defer r.peerToggleMu.Unlock() toggleCtx, cancel := context.WithTimeout(r.ctx, peerToggleTimeout) @@ -120,7 +134,12 @@ func (r *LocalBackend) closePeerClient() { } // PeerStatus returns the current peer-share session state for the IPC -// /peer/status endpoint. +// /peer/status endpoint. A nil peerClient reports the zero Status rather +// than panicking: construction degrades to a nil client, and the IPC +// handler serves this on every GET /peer/status. func (r *LocalBackend) PeerStatus() peer.Status { + if r.peerClient == nil { + return peer.Status{} + } return r.peerClient.CurrentStatus() } diff --git a/backend/radiance.go b/backend/radiance.go index 3f19c3ea..6133f511 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -187,9 +187,12 @@ func NewLocalBackend(ctx context.Context, opts Options) (*LocalBackend, error) { vpnClient := vpn.NewVPNClient(dataDir, slog.Default().With("service", "vpn"), opts.PlatformInterface) + // Degraded, not fatal, per the invariant above: a nil peerClient only + // disables Share My Connection, and must not cost the user their ability + // to report an issue. applyPeerShare and PeerStatus handle nil. peerClient, err := newPeerClient(platformDeviceID) if err != nil { - return nil, err + slog.Error("Loading peer client", "error", err) } ctx, cancel := context.WithCancel(ctx) @@ -603,17 +606,20 @@ func (r *LocalBackend) PatchSettings(updates settings.Settings) error { if _, ok := diff[k]; ok { r.splitTunnelMgr.SetEnabled(settings.GetBool(k)) } + // settings.Patch above already persisted the whole diff, so an early + // return here would leave a persisted key that no runtime state matches — + // exactly the divergence applyPeerShare's rollback exists to prevent. + // Run both handlers and join their errors. + var errs error if err := r.maybeRestartVPN(diff); err != nil { - return err + errs = errors.Join(errs, err) } - if _, ok := diff[settings.PeerShareEnabledKey]; ok { if err := r.applyPeerShare(settings.GetBool(settings.PeerShareEnabledKey)); err != nil { - return err + errs = errors.Join(errs, err) } } - - return nil + return errs } // maybeRestartVPN restarts the VPN connection if either the ad block or smart routing settings diff --git a/backend/radiance_test.go b/backend/radiance_test.go index 8797a7be..929790e1 100644 --- a/backend/radiance_test.go +++ b/backend/radiance_test.go @@ -510,3 +510,40 @@ func TestPatchSettings_PeerShareDispatches(t *testing.T) { assert.Equal(t, int64(1), fake.stopCalls.Load()) assert.False(t, fake.IsActive()) } + +// Construction degrades to a nil peerClient rather than failing, so that a +// peer-client problem can never cost the user the ability to report an issue. +// Everything reachable from IPC must tolerate that. + +func TestPeerStatus_NilClientReturnsZeroStatus(t *testing.T) { + r := newPeerTestBackend(t, nil) + r.peerClient = nil + + var got peer.Status + require.NotPanics(t, func() { got = r.PeerStatus() }, + "the IPC /peer/status handler calls this on every request") + assert.Equal(t, peer.Status{}, got) +} + +func TestApplyPeerShare_NilClientReportsUnavailableAndRollsBack(t *testing.T) { + r := newPeerTestBackend(t, nil) + r.peerClient = nil + require.NoError(t, settings.Patch(settings.Settings{settings.PeerShareEnabledKey: true})) + + err := r.applyPeerShare(true) + + require.Error(t, err, "enabling with no peer client must report the outage, not panic") + assert.ErrorContains(t, err, "peer share unavailable") + assert.False(t, settings.GetBool(settings.PeerShareEnabledKey), + "the setting must roll back so a persisted \"on\" doesn't survive with nothing behind it") +} + +func TestApplyPeerShare_NilClientDisableIsNoop(t *testing.T) { + r := newPeerTestBackend(t, nil) + r.peerClient = nil + + require.NotPanics(t, func() { + assert.NoError(t, r.applyPeerShare(false), + "turning it off when it was never on is not an error") + }) +} diff --git a/events/events.go b/events/events.go index 9fb64267..0586af1f 100644 --- a/events/events.go +++ b/events/events.go @@ -136,7 +136,9 @@ func Emit[T Event](evt T) { // Diagnostic hook; default no-op so high-frequency event types // don't flood logs in prod. Tests / debugging swap in a real // logger via SetEmitDebugLogger. - emitDebugLogger(key, len(cbs)) + if fn := emitDebugLogger.Load(); fn != nil { + (*fn)(key, len(cbs)) + } for _, cb := range cbs { go func() { defer func() { @@ -149,19 +151,21 @@ func Emit[T Event](evt T) { } } -// emitDebugLogger is invoked once per Emit with the event type and -// current subscriber count. Default is a no-op; callers (tests, -// diagnostic builds) swap in a real logger via SetEmitDebugLogger. -var emitDebugLogger = func(reflect.Type, int) {} +// emitDebugLogger holds the hook invoked once per Emit with the event type +// and current subscriber count; nil means no-op. Held atomically because +// Emit reads it from arbitrary goroutines — peer.Client's heartbeat and +// rotation loops emit for the process lifetime, so any post-startup +// SetEmitDebugLogger would otherwise race an in-flight Emit. +var emitDebugLogger atomic.Pointer[func(reflect.Type, int)] // SetEmitDebugLogger replaces the no-op diagnostic hook for the // duration of an investigation (e.g., tracking "events vanish" paths). -// Pass nil to restore the no-op default. Safe to call from main / -// init; not safe to call concurrently with Emit on the hot path. +// Pass nil to restore the no-op default. Safe to call concurrently with +// Emit. func SetEmitDebugLogger(fn func(eventType reflect.Type, subscriberCount int)) { if fn == nil { - emitDebugLogger = func(reflect.Type, int) {} + emitDebugLogger.Store(nil) return } - emitDebugLogger = fn + emitDebugLogger.Store(&fn) } diff --git a/events/events_test.go b/events/events_test.go new file mode 100644 index 00000000..c2aa56d7 --- /dev/null +++ b/events/events_test.go @@ -0,0 +1,95 @@ +package events + +import ( + "reflect" + "sync" + "testing" +) + +type debugHookEvent struct{} + +func (debugHookEvent) IsEvent() {} + +// SetEmitDebugLogger has to be safe against a concurrent Emit. peer.Client's +// heartbeat and rotation loops emit for the whole process lifetime, so any +// post-startup call to install the hook overlaps an in-flight Emit — and an +// unsynchronized write to a function-valued global racing a read is a data +// race, which `go test -race` fails on. Run with -race for this to be +// meaningful; without it, the test only shows neither side crashes. +func TestSetEmitDebugLogger_SafeConcurrentlyWithEmit(t *testing.T) { + t.Cleanup(func() { SetEmitDebugLogger(nil) }) + + sub := Subscribe(func(debugHookEvent) {}) + t.Cleanup(sub.Unsubscribe) + + const iterations = 200 + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for range iterations { + Emit(debugHookEvent{}) + } + }() + go func() { + defer wg.Done() + for i := range iterations { + if i%2 == 0 { + SetEmitDebugLogger(func(reflect.Type, int) {}) + } else { + SetEmitDebugLogger(nil) + } + } + }() + + wg.Wait() +} + +// The installed hook must actually receive the event type and subscriber +// count, and clearing it must stop delivery — otherwise the atomic swap could +// "fix" the race by silently never invoking the hook. +func TestSetEmitDebugLogger_ReceivesTypeAndCountThenClears(t *testing.T) { + t.Cleanup(func() { SetEmitDebugLogger(nil) }) + + sub := Subscribe(func(debugHookEvent) {}) + t.Cleanup(sub.Unsubscribe) + + var ( + mu sync.Mutex + types []reflect.Type + counts []int + ) + SetEmitDebugLogger(func(evtType reflect.Type, subscriberCount int) { + mu.Lock() + defer mu.Unlock() + types = append(types, evtType) + counts = append(counts, subscriberCount) + }) + + Emit(debugHookEvent{}) + + mu.Lock() + if len(types) != 1 { + mu.Unlock() + t.Fatalf("hook should have fired exactly once, got %d calls", len(types)) + } + if want := reflect.TypeFor[debugHookEvent](); types[0] != want { + mu.Unlock() + t.Fatalf("hook got event type %v, want %v", types[0], want) + } + if counts[0] != 1 { + mu.Unlock() + t.Fatalf("hook got subscriber count %d, want 1", counts[0]) + } + mu.Unlock() + + SetEmitDebugLogger(nil) + Emit(debugHookEvent{}) + + mu.Lock() + defer mu.Unlock() + if len(types) != 1 { + t.Fatalf("hook fired after being cleared: %d total calls", len(types)) + } +} diff --git a/peer/validate.go b/peer/validate.go index 4810effd..4dbd50ca 100644 --- a/peer/validate.go +++ b/peer/validate.go @@ -204,7 +204,19 @@ func isUnconditionalReject(body map[string]any, matchKey string) bool { if invert, _ := body["invert"].(bool); invert { return false } - allowed := map[string]bool{"action": true, "invert": true, matchKey: true} + // "type" is a discriminator, not a matcher, so its presence must not make + // an otherwise-unconditional rule look scoped. sing-box's marshaller drops + // it for default rules, but its parser accepts "default" explicitly, and + // launch_cfg is authored server-side rather than round-tripped through + // sing-box — so an inlined rule can legitimately carry it. Only the + // default forms are tolerated: any other type (e.g. "logical") brings its + // own fields, which the allow-list below still rejects. + if t, ok := body["type"]; ok { + if s, isStr := t.(string); !isStr || (s != "" && s != "default") { + return false + } + } + allowed := map[string]bool{"action": true, "invert": true, "type": true, matchKey: true} for k := range body { if !allowed[k] { return false diff --git a/peer/validate_test.go b/peer/validate_test.go index 2a306b57..1deb753a 100644 --- a/peer/validate_test.go +++ b/peer/validate_test.go @@ -324,3 +324,35 @@ func TestValidateAbuseRules_AcceptsExplicitInvertFalse(t *testing.T) { t.Fatalf("explicit invert=false should be treated as a pure reject, got: %v", err) } } + +// A launch_cfg may inline a default rule *and* state its type explicitly. +// sing-box's marshaller omits "type" for default rules, but its parser accepts +// it, and launch_cfg is authored server-side rather than round-tripped through +// sing-box — so the discriminator must not read as an extra constraint and +// make the peer refuse a config that does reject unconditionally. +func TestValidateAbuseRules_AcceptsInlinedExplicitDefaultType(t *testing.T) { + inlined := strings.ReplaceAll(minimalValidLaunchCfg, + `{"action":"reject","rule_set":`, + `{"type":"default","action":"reject","rule_set":`) + if inlined == minimalValidLaunchCfg { + t.Fatal("fixture substitution did not apply — test would be vacuous") + } + if err := validateAbuseRules(inlined); err != nil { + t.Fatalf("inlined rule carrying an explicit \"type\":\"default\" should pass, got: %v", err) + } +} + +// Tolerating the discriminator must not extend to types that carry their own +// matching fields. A logical rule scopes via mode/rules, so it is not an +// unconditional reject even though it says action=reject. +func TestValidateAbuseRules_RejectsNonDefaultRuleType(t *testing.T) { + bad := strings.Replace(minimalValidLaunchCfg, + `{"action":"reject","rule_set":["geosite-malware"]}`, + `{"type":"logical","action":"reject","rule_set":["geosite-malware"]}`, 1) + if bad == minimalValidLaunchCfg { + t.Fatal("fixture substitution did not apply — test would be vacuous") + } + if err := validateAbuseRules(bad); err == nil { + t.Fatal("a logical rule must not count as an unconditional reject") + } +} diff --git a/portforward/portforward.go b/portforward/portforward.go index 45c788ac..5491b4ab 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -48,6 +48,11 @@ type Forwarder struct { method string mapping *Mapping cancel context.CancelFunc + // closed marks that UnmapPort has begun tearing the mapping down. A + // renewal already past its pre-check consults this again afterwards, so + // an AddPortMapping that lands after the delete gets undone rather than + // left on the gateway forever. + closed bool } // ProbeUPnP reports whether IGD discovery on the local network turns up a @@ -105,6 +110,13 @@ func (f *Forwarder) MapPort(ctx context.Context, internalPort uint16, descriptio return nil, errors.New("forwarder already has an active mapping") } + // Bail before localIP's interface enumeration; runWithCtx only checks ctx + // once it is reached, so an already-cancelled caller would otherwise pay + // for work whose result is discarded. + if err := ctx.Err(); err != nil { + return nil, err + } + internalIP, err := localIP() if err != nil { return nil, fmt.Errorf("determine local ip: %w", err) @@ -166,6 +178,9 @@ func (f *Forwarder) UnmapPort(ctx context.Context) error { f.cancel() f.cancel = nil } + // Set before the delete and while holding the lock: a renewal blocked on + // f.mu will observe it and undo anything it re-added. + f.closed = true if f.mapping == nil { return nil } @@ -190,6 +205,17 @@ func (f *Forwarder) UnmapPort(ctx context.Context) error { // The peer's heartbeat path will surface that failure and auto-Stop the // session; routine 30-minute refresh of an hour-long requested lease // handles the common case where the router honors the requested duration. +const ( + // racedUnmapTimeout bounds the compensating delete issued when a renewal + // re-added a mapping after teardown. Short: the session is already going + // away and nothing waits on this. + racedUnmapTimeout = 10 * time.Second + // renewCallTimeout bounds how long a renewal waits for the gateway before + // giving up on learning whether the mapping was re-added. SOAP to a LAN + // gateway normally answers in milliseconds. + renewCallTimeout = 30 * time.Second +) + func (f *Forwarder) StartRenewal(ctx context.Context) { f.mu.Lock() defer f.mu.Unlock() @@ -208,6 +234,24 @@ func (f *Forwarder) StartRenewal(ctx context.Context) { go f.renewLoop(renewCtx, interval) } +// deleteRacedMapping removes a mapping that an in-flight renewal re-added +// after UnmapPort had already deleted it. The renewal ctx is cancelled by +// then, so this takes its own short deadline; best effort, since the only +// remaining recourse is the router's own lease expiry. +func (f *Forwarder) deleteRacedMapping(m *Mapping, client igdClient) { + ctx, cancel := context.WithTimeout(context.Background(), racedUnmapTimeout) + defer cancel() + if err := runWithCtx(ctx, func() error { + return client.DeletePortMapping("", m.ExternalPort, m.Protocol) + }); err != nil { + slog.Warn("portforward: could not remove mapping re-added by an in-flight renewal", + "err", err, "external_port", m.ExternalPort) + return + } + slog.Info("portforward: removed mapping re-added by an in-flight renewal", + "external_port", m.ExternalPort) +} + func (f *Forwarder) renewLoop(ctx context.Context, interval time.Duration) { t := time.NewTicker(interval) defer t.Stop() @@ -219,18 +263,52 @@ func (f *Forwarder) renewLoop(ctx context.Context, interval time.Duration) { f.mu.Lock() m := f.mapping client := f.client + closed := f.closed f.mu.Unlock() - if m == nil { + if m == nil || closed { return } // Most routers treat a re-issued AddPortMapping as "extend the // existing lease"; some replace it with a fresh one. Either is // fine here. - err := runWithCtx(ctx, func() error { - return client.AddPortMapping("", m.ExternalPort, "TCP", m.InternalPort, m.InternalIP, true, "Lantern peer share (renew)", uint32(m.LeaseDuration/time.Second)) - }) - if err != nil { - slog.Warn("portforward: lease renewal failed", "err", err, "external_port", m.ExternalPort) + // + // Deliberately NOT runWithCtx: that returns the moment ctx is + // cancelled while its goroutine keeps running, so the request can + // reach the gateway at any later point. The teardown check below + // is only sound if we know the call has finished, so wait for it + // and bound a wedged gateway with renewCallTimeout instead. + errCh := make(chan error, 1) + go func() { + errCh <- client.AddPortMapping("", m.ExternalPort, "TCP", m.InternalPort, m.InternalIP, true, "Lantern peer share (renew)", uint32(m.LeaseDuration/time.Second)) + }() + var landed bool + select { + case err := <-errCh: + landed = err == nil + if err != nil { + slog.Warn("portforward: lease renewal failed", "err", err, "external_port", m.ExternalPort) + } + case <-time.After(renewCallTimeout): + // Outcome unknowable — the call may still land. Assume it did + // so teardown removes it rather than leaving a forward behind. + landed = true + slog.Warn("portforward: lease renewal timed out; treating the mapping as re-added", + "external_port", m.ExternalPort, "timeout", renewCallTimeout) + } + + // UnmapPort may have deleted the mapping while the renewal was in + // flight, in which case the renewal just re-added an inbound + // forward to this host that no other code path removes — + // permanent on routers that ignore the requested lease. Blocking + // on f.mu orders this strictly after UnmapPort's delete. + f.mu.Lock() + teardownRaced := f.closed + f.mu.Unlock() + if teardownRaced { + if landed { + f.deleteRacedMapping(m, client) + } + return } } } diff --git a/portforward/portforward_test.go b/portforward/portforward_test.go index c7b7c22d..49d8ca64 100644 --- a/portforward/portforward_test.go +++ b/portforward/portforward_test.go @@ -275,3 +275,66 @@ func TestForwarder_MapPort_GatewayErrorWrapsErrNoPortForwarding(t *testing.T) { assert.ErrorIs(t, err, ErrNoPortForwarding, "callers must be able to detect via errors.Is") assert.ErrorContains(t, err, "ConflictInMappingEntry", "underlying gateway error must survive for diagnostics") } + +// A renewal that was already mid-call when UnmapPort deleted the mapping +// re-adds an inbound forward to this host, and nothing else would ever remove +// it — permanent on routers that ignore the requested lease. The renewal must +// notice the teardown and delete what it re-added. +func TestForwarder_RenewalRacingTeardown_DeletesWhatItReAdded(t *testing.T) { + release := make(chan struct{}) + c := &fakeIGD{addBlock: release} + f := newTestForwarder(t, c) + f.mapping = &Mapping{ + ExternalPort: 15000, InternalPort: 15000, InternalIP: "192.168.1.10", + Protocol: "TCP", LeaseDuration: time.Hour, + } + + ctx, cancel := context.WithCancel(context.Background()) + f.cancel = cancel + loopDone := make(chan struct{}) + go func() { f.renewLoop(ctx, time.Millisecond); close(loopDone) }() + + // Park the renewal inside AddPortMapping, past its teardown pre-check. + require.Eventually(t, func() bool { return c.addCalls.Load() >= 1 }, + 2*time.Second, time.Millisecond, "renewal never reached AddPortMapping") + + require.NoError(t, f.UnmapPort(context.Background())) + deletesAfterUnmap := c.deleteCalls.Load() + require.Equal(t, int64(1), deletesAfterUnmap, "UnmapPort should have deleted once") + + // Let the in-flight renewal complete — it lands after the delete. + close(release) + + select { + case <-loopDone: + case <-time.After(3 * time.Second): + t.Fatal("renewLoop did not exit after teardown") + } + assert.Equal(t, int64(2), c.deleteCalls.Load(), + "the renewal must delete the mapping it re-added after teardown") + assert.Equal(t, uint16(15000), c.lastDelete.externalPort) +} + +// Once teardown has run, a later tick must not touch the gateway at all. +func TestForwarder_RenewalAfterTeardown_DoesNotReAdd(t *testing.T) { + c := &fakeIGD{} + f := newTestForwarder(t, c) + f.mapping = &Mapping{ + ExternalPort: 15001, InternalPort: 15001, InternalIP: "192.168.1.10", + Protocol: "TCP", LeaseDuration: time.Hour, + } + require.NoError(t, f.UnmapPort(context.Background())) + require.Equal(t, int64(1), c.deleteCalls.Load()) + + // renewLoop started (or ticking) after teardown must exit without adding. + done := make(chan struct{}) + go func() { f.renewLoop(context.Background(), time.Millisecond); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("renewLoop did not exit once the mapping was torn down") + } + assert.Equal(t, int64(0), c.addCalls.Load(), + "no AddPortMapping may be issued after UnmapPort") + assert.Equal(t, int64(1), c.deleteCalls.Load(), "no compensating delete was needed") +} From a4913ef25e7169d2d4b32d1a64c973016b9a0c71 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 5 Aug 2026 17:41:41 -0600 Subject: [PATCH 61/63] Address Copilot review round 2 on #589 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peer: credential rotation now re-runs validateAbuseRules on the freshly fetched launch_cfg. The gate only ran in Start, so a server-side regression would have reached every long-lived peer on its next hourly rotation — precisely what the check exists to prevent. A rejected rotation deregisters the orphan route and leaves the current, already validated box serving. portforward: MapPort had the same abandoned-goroutine leak just fixed in renewLoop. runWithCtx returns on cancellation while AddPortMapping keeps going, so the gateway could accept a mapping after MapPort returned an error — and with f.mapping unset, UnmapPort short-circuits and nothing ever removes the forward. MapPort now waits for the outcome and deletes the mapping if the caller has given up. Both tests were verified to fail with their fix reverted. --- peer/peer.go | 10 ++++++ peer/peer_test.go | 62 +++++++++++++++++++++++++++++++++ portforward/portforward.go | 30 ++++++++++++++-- portforward/portforward_test.go | 41 ++++++++++++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index c447b45a..8d18a773 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -790,6 +790,16 @@ func (c *Client) rotateCreds(ctx context.Context) error { } } + // Same defence-in-depth gate Start applies, because rotation installs a + // freshly fetched launch_cfg on an already-running peer. Validating only + // at Start would let a server-side regression reach every long-lived peer + // on its next hourly rotation. Failing here keeps the current, already + // validated box serving. + if err := validateAbuseRules(regResp.ServerConfig); err != nil { + cleanupNewRoute(err) + return fmt.Errorf("rotated launch_cfg failed abuse-rule sanity check: %w", err) + } + options, err := ensurePeerOutboundsBypassVPN(regResp.ServerConfig) if err != nil { cleanupNewRoute(err) diff --git a/peer/peer_test.go b/peer/peer_test.go index 4557c522..46ff5ab6 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -1064,3 +1064,65 @@ func TestAPI_ForwardsCommonHeaders(t *testing.T) { assert.NotEmpty(t, c.appName, "%s must carry %s", path, common.AppNameHeader) } } + +// Rotation installs a freshly fetched launch_cfg on an already-running peer, +// so it has to re-apply the same abuse-rule gate Start does. Validating only +// at Start would let a server-side regression reach every long-lived peer on +// its next hourly rotation — the exact scenario the gate exists to prevent. +// A rejected rotation must leave the current, already validated box serving. +func TestClient_RotationRejectsLaunchCfgMissingAbuseRules(t *testing.T) { + fwd := &fakeForwarder{externalIP: "203.0.113.42"} + srv := newStubServer(t) + + var registerSeq atomic.Int64 + srv.registerRespFn = func() RegisterResponse { + n := registerSeq.Add(1) + cfg := minimalValidLaunchCfg + if n > 1 { + // The rotation response drops the route block entirely. + cfg = `{"inbounds":[{"type":"samizdat","tag":"samizdat-in"}]}` + } + return RegisterResponse{ + RouteID: fmt.Sprintf("00000000-0000-0000-0000-00000000000%d", n), + ServerConfig: cfg, + HeartbeatIntervalSeconds: 60, + } + } + + var ( + boxesMu sync.Mutex + boxes []*fakeBoxService + ) + c := newTestClient(t, fwd, &fakeBoxService{}, srv, func(cfg *Config) { + cfg.CredRotationInterval = 30 * time.Millisecond + cfg.HeartbeatInterval = time.Hour + cfg.BuildBoxService = func(_ context.Context, _ string) (boxService, error) { + b := &fakeBoxService{} + boxesMu.Lock() + boxes = append(boxes, b) + boxesMu.Unlock() + return b, nil + } + }) + + require.NoError(t, c.Start(context.Background())) + t.Cleanup(func() { _ = c.Stop(context.Background()) }) + + // Give the rotation loop several chances to attempt the bad config. + require.Eventually(t, func() bool { return srv.registerCount.Load() >= 2 }, + 3*time.Second, 10*time.Millisecond, "rotation never re-registered") + time.Sleep(100 * time.Millisecond) + + boxesMu.Lock() + built := len(boxes) + firstClosed := boxes[0].closed.Load() + boxesMu.Unlock() + + assert.Equal(t, 1, built, + "a launch_cfg missing abuse rules must be rejected before a new box is built") + assert.False(t, firstClosed, + "the original validated box must keep serving when a rotation is rejected") + assert.True(t, c.IsActive(), "the peer must stay active after a rejected rotation") + assert.Positive(t, srv.deregisterCount.Load(), + "the orphan route created for the rejected rotation must be deregistered") +} diff --git a/portforward/portforward.go b/portforward/portforward.go index 5491b4ab..cd5955bb 100644 --- a/portforward/portforward.go +++ b/portforward/portforward.go @@ -128,9 +128,31 @@ func (f *Forwarder) MapPort(ctx context.Context, internalPort uint16, descriptio // retry with a different internalPort. externalPort := internalPort client := f.client - err = runWithCtx(ctx, func() error { - return client.AddPortMapping("", externalPort, "TCP", internalPort, internalIP, true, description, requestedLease) - }) + // Deliberately NOT runWithCtx, for the same reason as renewLoop: it + // returns the moment ctx is cancelled while its goroutine keeps running, + // so the mapping could land on the gateway after we returned an error and + // without f.mapping recording it — and UnmapPort short-circuits on a nil + // f.mapping, so nothing would ever remove it. Wait for the outcome, then + // clean up if the caller has given up. + addErrCh := make(chan error, 1) + go func() { + addErrCh <- client.AddPortMapping("", externalPort, "TCP", internalPort, internalIP, true, description, requestedLease) + }() + var addLanded bool + select { + case err = <-addErrCh: + addLanded = err == nil + case <-time.After(addCallTimeout): + // Outcome unknowable; assume it landed so the cleanup below removes it. + addLanded = true + err = fmt.Errorf("add port mapping timed out after %s", addCallTimeout) + } + if ctxErr := ctx.Err(); ctxErr != nil && addLanded { + // The caller gave up but the gateway accepted the mapping. Remove it — + // otherwise it survives with no Forwarder tracking it. + f.deleteRacedMapping(&Mapping{ExternalPort: externalPort, Protocol: "TCP"}, client) + return nil, fmt.Errorf("add port mapping: %w", ctxErr) + } if err != nil { // Propagate ctx cancellation/deadline verbatim so callers can retry // rather than treating it as a permanent "this network won't work". @@ -214,6 +236,8 @@ const ( // giving up on learning whether the mapping was re-added. SOAP to a LAN // gateway normally answers in milliseconds. renewCallTimeout = 30 * time.Second + // addCallTimeout is the same bound for the initial MapPort call. + addCallTimeout = 30 * time.Second ) func (f *Forwarder) StartRenewal(ctx context.Context) { diff --git a/portforward/portforward_test.go b/portforward/portforward_test.go index 49d8ca64..34cafd43 100644 --- a/portforward/portforward_test.go +++ b/portforward/portforward_test.go @@ -338,3 +338,44 @@ func TestForwarder_RenewalAfterTeardown_DoesNotReAdd(t *testing.T) { "no AddPortMapping may be issued after UnmapPort") assert.Equal(t, int64(1), c.deleteCalls.Load(), "no compensating delete was needed") } + +// A caller that gives up mid-MapPort must not leave a mapping behind: the +// gateway may still accept it, and because f.mapping was never recorded, +// UnmapPort would short-circuit and nothing would ever remove the forward. +func TestForwarder_MapPort_CancelledMidCall_RemovesAcceptedMapping(t *testing.T) { + release := make(chan struct{}) + c := &fakeIGD{addBlock: release} + f := newTestForwarder(t, c) + + ctx, cancel := context.WithCancel(context.Background()) + type result struct { + m *Mapping + err error + } + res := make(chan result, 1) + go func() { + m, err := f.MapPort(ctx, 15100, "test") + res <- result{m, err} + }() + + // Park inside AddPortMapping, then give up. + require.Eventually(t, func() bool { return c.addCalls.Load() >= 1 }, + 2*time.Second, time.Millisecond, "MapPort never reached AddPortMapping") + cancel() + close(release) // the gateway accepts it anyway + + var got result + select { + case got = <-res: + case <-time.After(3 * time.Second): + t.Fatal("MapPort did not return") + } + + require.Error(t, got.err, "a cancelled caller must get an error") + assert.ErrorIs(t, got.err, context.Canceled) + assert.Nil(t, got.m) + assert.Nil(t, f.mapping, "no mapping should be recorded") + assert.Equal(t, int64(1), c.deleteCalls.Load(), + "the accepted-but-unreported mapping must be deleted, or it survives untracked") + assert.Equal(t, uint16(15100), c.lastDelete.externalPort) +} From 20b4cd5c343af3b32a836a4f0473cdd11f8e46bf Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 12 Aug 2026 06:16:32 -0600 Subject: [PATCH 62/63] peer: correct peerBoxContext's doc on value precedence The doc said every value other than Deadline/Done/Err came from base, but Value checks the embedded caller context first and only falls back to base. State the actual precedence, and why it matters: base holds the single captured registry instance libbox registers into and reads back, so a caller-supplied value shadowing it is the thing to watch for. Co-Authored-By: Claude Opus 5 (1M context) --- peer/peer.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/peer/peer.go b/peer/peer.go index 35eae071..615a5400 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -1057,8 +1057,10 @@ func newPeerBoxContext(ctx context.Context) context.Context { } // peerBoxContext resolves Deadline/Done/Err from the embedded caller -// context so a Stop-induced cancel propagates into box internals, and -// every other value from base. +// context so a Stop-induced cancel propagates into box internals. Values +// come from the caller first and from base only as a fallback, so anything +// the caller carries shadows base — which matters because base holds the one +// captured registry instance libbox both registers into and reads back. type peerBoxContext struct { context.Context base context.Context From d6e721420adddc16f1c32c23afd4d1ca5f525d3d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 12 Aug 2026 09:25:34 -0600 Subject: [PATCH 63/63] events: start the debug-hook test doc comments with the test names Go doc convention (and this repo's CLAUDE.md) wants a doc comment to lead with the identifier it documents; both of these led with the subject under test instead. Wording is otherwise unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- events/events_test.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/events/events_test.go b/events/events_test.go index c2aa56d7..5d943151 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -10,12 +10,13 @@ type debugHookEvent struct{} func (debugHookEvent) IsEvent() {} -// SetEmitDebugLogger has to be safe against a concurrent Emit. peer.Client's -// heartbeat and rotation loops emit for the whole process lifetime, so any -// post-startup call to install the hook overlaps an in-flight Emit — and an -// unsynchronized write to a function-valued global racing a read is a data -// race, which `go test -race` fails on. Run with -race for this to be -// meaningful; without it, the test only shows neither side crashes. +// TestSetEmitDebugLogger_SafeConcurrentlyWithEmit checks that installing the +// hook is safe against a concurrent Emit. peer.Client's heartbeat and rotation +// loops emit for the whole process lifetime, so any post-startup call to +// install the hook overlaps an in-flight Emit — and an unsynchronized write to +// a function-valued global racing a read is a data race, which `go test -race` +// fails on. Run with -race for this to be meaningful; without it, the test +// only shows neither side crashes. func TestSetEmitDebugLogger_SafeConcurrentlyWithEmit(t *testing.T) { t.Cleanup(func() { SetEmitDebugLogger(nil) }) @@ -46,9 +47,10 @@ func TestSetEmitDebugLogger_SafeConcurrentlyWithEmit(t *testing.T) { wg.Wait() } -// The installed hook must actually receive the event type and subscriber -// count, and clearing it must stop delivery — otherwise the atomic swap could -// "fix" the race by silently never invoking the hook. +// TestSetEmitDebugLogger_ReceivesTypeAndCountThenClears checks that the +// installed hook receives the event type and subscriber count, and that +// clearing it stops delivery — otherwise the atomic swap could "fix" the race +// by silently never invoking the hook. func TestSetEmitDebugLogger_ReceivesTypeAndCountThenClears(t *testing.T) { t.Cleanup(func() { SetEmitDebugLogger(nil) })