Skip to content

Commit b2b3938

Browse files
myleshortonAdam Fiskclaude
authored
peer: manual port-forward override for UPnP-less networks (#500)
* peer: read PeerManualPortKey setting alongside RADIANCE_PEER_EXTERNAL_PORT 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: <int>}) 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) <noreply@anthropic.com> * portforward: extract manual port forwarder to its own file 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 <noreply@anthropic.com> * peer/portforward/settings: address Copilot review on #500 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 <noreply@anthropic.com> * peer/portforward: address Copilot review on #500 (round 2) 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 <noreply@anthropic.com> --------- Co-authored-by: Adam Fisk <afisk@mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06ceb1f commit b2b3938

5 files changed

Lines changed: 193 additions & 92 deletions

File tree

common/settings/settings.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ const (
5656
AdBlockKey _key = "ad_block" // bool
5757
AutoConnectKey _key = "auto_connect" // bool
5858
PeerShareEnabledKey _key = "peer_share_enabled" // bool
59+
// PeerManualPortKey is the TCP port number the user has manually
60+
// forwarded on their router for the peer-proxy inbound (single-
61+
// port 1:1 NAT). Valid range is 1..65535; 0 means unset, in which
62+
// case the peer falls back to UPnP discovery. Out-of-range values
63+
// (negative, > 65535) are logged on read and treated as unset
64+
// rather than silently wrapping to a wrong port. Surfaced as an
65+
// Advanced setting in the Share My Connection UI for users on
66+
// networks where UPnP is disabled or unavailable.
67+
PeerManualPortKey _key = "peer_manual_port" // int (0 = unset; 1..65535 = manual port)
5968
SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored
6069

6170
PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation]

peer/peer.go

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"fmt"
88
"log/slog"
99
"math/rand/v2"
10-
"strconv"
1110
"sync"
1211
"sync/atomic"
1312
"time"
@@ -17,45 +16,11 @@ import (
1716
box "github.com/getlantern/lantern-box"
1817
"github.com/getlantern/lantern-box/tracker/peerconn"
1918
"github.com/getlantern/radiance/common/env"
19+
"github.com/getlantern/radiance/common/settings"
2020
"github.com/getlantern/radiance/events"
2121
"github.com/getlantern/radiance/portforward"
2222
)
2323

24-
// manualPortForwarder satisfies the portForwarder interface without doing
25-
// any UPnP work. Used when env.PeerExternalPort is set.
26-
type manualPortForwarder struct{ port uint16 }
27-
28-
func (m *manualPortForwarder) MapPort(_ context.Context, _ uint16, _ string) (*portforward.Mapping, error) {
29-
return &portforward.Mapping{
30-
ExternalPort: m.port,
31-
InternalPort: m.port,
32-
Method: "manual-env",
33-
}, nil
34-
}
35-
func (m *manualPortForwarder) UnmapPort(_ context.Context) error { return nil }
36-
func (m *manualPortForwarder) StartRenewal(_ context.Context) {}
37-
func (m *manualPortForwarder) ExternalIP(_ context.Context) (string, error) {
38-
// An empty external IP signals the server to use the address it
39-
// observed on the inbound request — when the user has supplied a
40-
// manual port but no WAN IP, the server's view is the right answer.
41-
return "", nil
42-
}
43-
44-
// manualPort returns the parsed env.PeerExternalPort value, or 0 if unset
45-
// or invalid.
46-
func manualPort() uint16 {
47-
raw := env.GetString(env.PeerExternalPort)
48-
if raw == "" {
49-
return 0
50-
}
51-
p, err := strconv.Atoi(raw)
52-
if err != nil || p < 1 || p > 65535 {
53-
slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), "value", raw)
54-
return 0
55-
}
56-
return uint16(p)
57-
}
58-
5924
// StatusEvent fires whenever the Client's session state changes — successful
6025
// Start, user Stop, or auto-Stop on a 404 heartbeat.
6126
type StatusEvent struct {
@@ -209,10 +174,52 @@ func NewClient(cfg Config) (*Client, error) {
209174
}
210175
if cfg.NewForwarder == nil {
211176
cfg.NewForwarder = func(ctx context.Context) (portForwarder, error) {
212-
if p := manualPort(); p != 0 {
213-
slog.Info("peer client using manual port forward",
214-
"port", p, "env", env.PeerExternalPort.String())
215-
return &manualPortForwarder{port: p}, nil
177+
// Manual port-forward override. Use case: networks where
178+
// UPnP is disabled or unavailable (router has UPnP off for
179+
// security, ISP-provided gateways without IGD, networks
180+
// behind double-NAT) but the user has manually configured
181+
// a port forward on their router. We trust the user's
182+
// config — no UPnP roundtrip — and report the configured
183+
// port as both the external and internal port (the 1:1
184+
// case every consumer router exposes).
185+
//
186+
// Resolution order:
187+
// 1. "peer_manual_port" setting (Advanced UI)
188+
// 2. RADIANCE_PEER_EXTERNAL_PORT env var (developer /
189+
// power-user override)
190+
// 3. fall through to UPnP discovery
191+
//
192+
// Persisted names are quoted so the comment stays accurate
193+
// if Go identifiers move or rename.
194+
//
195+
// Range-check the setting before casting to uint16 — a raw
196+
// uint16 cast silently wraps negative values (-5 → 65531)
197+
// and values above the port space (70000 → 4464), which
198+
// would register a port we don't listen on (or, worse, one
199+
// we do listen on for a different service). Out-of-range
200+
// values fall through to env-var and then UPnP as if the
201+
// setting were unset.
202+
if raw := settings.GetInt(settings.PeerManualPortKey); raw != 0 {
203+
if raw < 1 || raw > 65535 {
204+
slog.Warn("ignoring out-of-range peer_manual_port setting; falling through to env / UPnP",
205+
"value", raw)
206+
} else {
207+
port := uint16(raw)
208+
slog.Info("peer client using manual port forward",
209+
"port", port, "source", "setting")
210+
return portforward.NewManualForwarder(port), nil
211+
}
212+
}
213+
if raw := env.GetString(env.PeerExternalPort); raw != "" {
214+
port, err := portforward.ParseManualPort(raw)
215+
if err != nil {
216+
slog.Warn("ignoring invalid "+env.PeerExternalPort.String(),
217+
"value", raw, "error", err)
218+
} else {
219+
slog.Info("peer client using manual port forward",
220+
"port", port, "source", env.PeerExternalPort.String())
221+
return portforward.NewManualForwarder(port), nil
222+
}
216223
}
217224
// Explicitly return a nil interface on error — `return
218225
// portforward.NewForwarder(ctx)` collapses the (*Forwarder, error)

peer/peer_test.go

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -627,58 +627,6 @@ func TestPickInternalPort_InRange(t *testing.T) {
627627
}
628628
}
629629

630-
// manualPort parses the RADIANCE_PEER_EXTERNAL_PORT env var. Unset, empty,
631-
// non-numeric, and out-of-range values all collapse to 0, which the
632-
// NewClient default factory treats as "no override → use UPnP discovery".
633-
// Only a 1..65535 value selects the manual path.
634-
func TestManualPort(t *testing.T) {
635-
tests := []struct {
636-
name string
637-
env string
638-
want uint16
639-
}{
640-
{"unset", "", 0},
641-
{"valid mid-range", "5698", 5698},
642-
{"valid low boundary", "1", 1},
643-
{"valid high boundary", "65535", 65535},
644-
{"non-numeric", "abc", 0},
645-
{"zero", "0", 0},
646-
{"negative", "-5", 0},
647-
{"above uint16", "65536", 0},
648-
{"way above uint16", "99999", 0},
649-
}
650-
for _, tc := range tests {
651-
t.Run(tc.name, func(t *testing.T) {
652-
t.Setenv("RADIANCE_PEER_EXTERNAL_PORT", tc.env)
653-
assert.Equal(t, tc.want, manualPort())
654-
})
655-
}
656-
}
657-
658-
// manualPortForwarder must satisfy the portForwarder contract: MapPort
659-
// returns a Mapping using the configured port for both internal and
660-
// external (no rewrite — that's the user's responsibility), UnmapPort
661-
// and StartRenewal are no-ops, and ExternalIP returns "" so the server
662-
// substitutes the IP it observed on the request.
663-
func TestManualPortForwarder(t *testing.T) {
664-
f := &manualPortForwarder{port: 5698}
665-
666-
m, err := f.MapPort(context.Background(), 30001, "ignored")
667-
require.NoError(t, err)
668-
assert.Equal(t, uint16(5698), m.ExternalPort)
669-
assert.Equal(t, uint16(5698), m.InternalPort, "external==internal — user mapped them themselves")
670-
assert.Equal(t, "manual-env", m.Method)
671-
672-
require.NoError(t, f.UnmapPort(context.Background()), "UnmapPort is a no-op for manual forwarders")
673-
674-
// StartRenewal must not panic or block.
675-
f.StartRenewal(context.Background())
676-
677-
ip, err := f.ExternalIP(context.Background())
678-
require.NoError(t, err)
679-
assert.Empty(t, ip, "empty ip signals server to use observed source address")
680-
}
681-
682630
func TestAPIError_StringFormat(t *testing.T) {
683631
e := &APIError{Status: 422, Body: "could not connect to peer port"}
684632
assert.Contains(t, e.Error(), "422")

portforward/manual.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package portforward
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
)
8+
9+
// ManualForwarder exposes the same Map/Unmap/StartRenewal/ExternalIP
10+
// surface as Forwarder but does no UPnP work. The user is expected to
11+
// have configured a port forward on their router by hand (single-port
12+
// 1:1 NAT — every consumer router exposes port forwarding as a single
13+
// port number) and supplied the port number out-of-band.
14+
//
15+
// Use case: networks where UPnP is disabled or unavailable (router has
16+
// UPnP off for security, ISP-provided gateways without IGD, networks
17+
// behind double-NAT). The UPnP-based Forwarder fails in those
18+
// environments; this type lets callers bypass discovery entirely.
19+
type ManualForwarder struct {
20+
port uint16
21+
}
22+
23+
// NewManualForwarder builds a ManualForwarder for a pre-configured
24+
// router port forward. port must be in 1..65535; the caller is
25+
// responsible for validating its input before calling.
26+
func NewManualForwarder(port uint16) *ManualForwarder {
27+
return &ManualForwarder{port: port}
28+
}
29+
30+
// ParseManualPort parses a string into a TCP port number. Values outside
31+
// 1..65535 return an error so callers can log and fall through to UPnP
32+
// discovery rather than register a non-listening port with the server.
33+
func ParseManualPort(s string) (uint16, error) {
34+
p, err := strconv.Atoi(s)
35+
if err != nil {
36+
return 0, fmt.Errorf("parse %q: %w", s, err)
37+
}
38+
if p < 1 || p > 65535 {
39+
return 0, fmt.Errorf("port %d out of range (1..65535)", p)
40+
}
41+
return uint16(p), nil
42+
}
43+
44+
// MapPort reports the configured port as both external and internal. The
45+
// router-side rule is already in place; nothing to do at the protocol
46+
// layer.
47+
func (m *ManualForwarder) MapPort(_ context.Context, _ uint16, _ string) (*Mapping, error) {
48+
return &Mapping{
49+
ExternalPort: m.port,
50+
InternalPort: m.port,
51+
Method: "manual",
52+
}, nil
53+
}
54+
55+
// UnmapPort is a no-op: the user owns the router rule and is responsible
56+
// for removing it.
57+
func (m *ManualForwarder) UnmapPort(_ context.Context) error { return nil }
58+
59+
// StartRenewal is a no-op: manually-configured rules don't carry a UPnP
60+
// lease and don't need refreshing.
61+
func (m *ManualForwarder) StartRenewal(_ context.Context) {}
62+
63+
// ExternalIP returns the empty string deliberately. With a manual port
64+
// forward we have no UPnP gateway to ask for the WAN address, and
65+
// probing a public IP service from the client adds a network roundtrip
66+
// for information lantern-cloud already has — the server observes the
67+
// peer's source address on the register call and uses that as the
68+
// canonical external IP when this field is empty.
69+
func (m *ManualForwarder) ExternalIP(_ context.Context) (string, error) {
70+
return "", nil
71+
}

portforward/manual_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package portforward
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// ParseManualPort accepts 1..65535 verbatim and rejects everything else
12+
// with an error so callers can log + fall through to UPnP discovery
13+
// rather than register a non-listening port with lantern-cloud.
14+
func TestParseManualPort(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
input string
18+
want uint16
19+
wantErr bool
20+
}{
21+
{"valid mid-range", "5698", 5698, false},
22+
{"valid low boundary", "1", 1, false},
23+
{"valid high boundary", "65535", 65535, false},
24+
{"empty", "", 0, true},
25+
{"non-numeric", "abc", 0, true},
26+
{"zero", "0", 0, true},
27+
{"negative", "-5", 0, true},
28+
{"above uint16", "65536", 0, true},
29+
{"way above uint16", "99999", 0, true},
30+
}
31+
for _, tc := range tests {
32+
t.Run(tc.name, func(t *testing.T) {
33+
got, err := ParseManualPort(tc.input)
34+
if tc.wantErr {
35+
assert.Error(t, err)
36+
assert.Equal(t, uint16(0), got)
37+
return
38+
}
39+
require.NoError(t, err)
40+
assert.Equal(t, tc.want, got)
41+
})
42+
}
43+
}
44+
45+
// ManualForwarder satisfies the portForwarder contract: MapPort returns
46+
// a Mapping with external==internal port and the "manual" method tag,
47+
// UnmapPort and StartRenewal are no-ops, ExternalIP returns "" so the
48+
// server substitutes the IP it observed on the register call.
49+
func TestManualForwarder(t *testing.T) {
50+
f := NewManualForwarder(5698)
51+
52+
m, err := f.MapPort(context.Background(), 30001, "ignored")
53+
require.NoError(t, err)
54+
assert.Equal(t, uint16(5698), m.ExternalPort)
55+
assert.Equal(t, uint16(5698), m.InternalPort, "external==internal — user mapped them themselves")
56+
assert.Equal(t, "manual", m.Method)
57+
58+
require.NoError(t, f.UnmapPort(context.Background()), "UnmapPort is a no-op for manual forwarders")
59+
60+
// StartRenewal must not panic or block.
61+
f.StartRenewal(context.Background())
62+
63+
ip, err := f.ExternalIP(context.Background())
64+
require.NoError(t, err)
65+
assert.Empty(t, ip, "empty IP signals server to use observed source address")
66+
}

0 commit comments

Comments
 (0)