diff --git a/backend/peer_share.go b/backend/peer_share.go index 93edbe5f..b488a106 100644 --- a/backend/peer_share.go +++ b/backend/peer_share.go @@ -55,16 +55,21 @@ 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", "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) } + slog.Info("peer share start succeeded") 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 } @@ -85,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) } }() } @@ -110,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 4a63f07a..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.Debug("Detected public IP", "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/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/api.go b/peer/api.go index 05752402..694d724b 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" ) @@ -47,32 +48,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 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} } 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 @@ -87,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.go b/peer/peer.go index 500ae7a5..73222e10 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -7,15 +7,53 @@ 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) { + // 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 { @@ -111,6 +149,11 @@ func NewClient(cfg Config) (*Client, error) { } if cfg.NewForwarder == nil { cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) { + 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 @@ -233,6 +276,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 @@ -446,10 +500,41 @@ 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. +// +// 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". +// +// 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. func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) { - bs, err := libbox.NewServiceWithContext(ctx, options, nil) + 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 dd24c0ea..e465d2ea 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" ) @@ -139,15 +140,19 @@ 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 + lastVerifyReq atomic.Value // LifecycleRequest } func newStubServer(t *testing.T) *stubServer { @@ -155,6 +160,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{ @@ -163,6 +169,10 @@ func newStubServer(t *testing.T) *stubServer { HeartbeatIntervalSeconds: 60, }, } + // Mount handlers under /v1 so the test mirrors production's versioned + // 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) @@ -176,6 +186,18 @@ func newStubServer(t *testing.T) *stubServer { } _ = json.NewEncoder(w).Encode(s.registerResp) }) + 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("/v1/peer/heartbeat", func(w http.ResponseWriter, r *http.Request) { s.heartbeatCount.Add(1) s.heartbeatDeviceID.Store(r.Header.Get("X-Lantern-Device-Id")) @@ -205,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 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 }, @@ -243,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{} @@ -570,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") @@ -610,3 +719,126 @@ 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() +} + +// 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) + } +}