Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions backend/peer_share.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
}()
}
Expand All @@ -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)
}
}

Expand Down
6 changes: 5 additions & 1 deletion backend/radiance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}()

Expand Down
4 changes: 4 additions & 0 deletions common/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
40 changes: 33 additions & 7 deletions peer/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"net/http"

"github.com/getlantern/radiance/common"
"github.com/getlantern/radiance/common/settings"
)

Expand Down Expand Up @@ -47,32 +48,48 @@ 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 ".../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.).
Comment thread
myleshorton marked this conversation as resolved.
Outdated
Comment thread
myleshorton marked this conversation as resolved.
Outdated
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
Expand All @@ -87,14 +104,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).
Expand Down
88 changes: 87 additions & 1 deletion peer/peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +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.
Comment thread
myleshorton marked this conversation as resolved.
Outdated
Comment thread
myleshorton marked this conversation as resolved.
Outdated
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 {
Expand Down Expand Up @@ -111,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
Comment thread
myleshorton marked this conversation as resolved.
}
// 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
Expand Down Expand Up @@ -233,6 +277,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 {
Comment thread
myleshorton marked this conversation as resolved.
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
Expand Down Expand Up @@ -446,10 +501,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.
//
// 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.
Comment thread
myleshorton marked this conversation as resolved.
Outdated
Comment thread
myleshorton marked this conversation as resolved.
Outdated
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)
}
Loading
Loading