Skip to content

Commit 22d1533

Browse files
myleshortonclaude
andcommitted
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>
1 parent 0c84c5a commit 22d1533

4 files changed

Lines changed: 148 additions & 93 deletions

File tree

peer/peer.go

Lines changed: 11 additions & 41 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"
@@ -22,41 +21,6 @@ import (
2221
"github.com/getlantern/radiance/portforward"
2322
)
2423

25-
// manualPortForwarder satisfies the portForwarder interface without doing
26-
// any UPnP work. Used when env.PeerExternalPort is set.
27-
type manualPortForwarder struct{ port uint16 }
28-
29-
func (m *manualPortForwarder) MapPort(_ context.Context, _ uint16, _ string) (*portforward.Mapping, error) {
30-
return &portforward.Mapping{
31-
ExternalPort: m.port,
32-
InternalPort: m.port,
33-
Method: "manual-env",
34-
}, nil
35-
}
36-
func (m *manualPortForwarder) UnmapPort(_ context.Context) error { return nil }
37-
func (m *manualPortForwarder) StartRenewal(_ context.Context) {}
38-
func (m *manualPortForwarder) ExternalIP(_ context.Context) (string, error) {
39-
// An empty external IP signals the server to use the address it
40-
// observed on the inbound request — when the user has supplied a
41-
// manual port but no WAN IP, the server's view is the right answer.
42-
return "", nil
43-
}
44-
45-
// manualPort returns the parsed env.PeerExternalPort value, or 0 if unset
46-
// or invalid.
47-
func manualPort() uint16 {
48-
raw := env.GetString(env.PeerExternalPort)
49-
if raw == "" {
50-
return 0
51-
}
52-
p, err := strconv.Atoi(raw)
53-
if err != nil || p < 1 || p > 65535 {
54-
slog.Warn("ignoring invalid "+env.PeerExternalPort.String(), "value", raw)
55-
return 0
56-
}
57-
return uint16(p)
58-
}
59-
6024
// StatusEvent fires whenever the Client's session state changes — successful
6125
// Start, user Stop, or auto-Stop on a 404 heartbeat.
6226
type StatusEvent struct {
@@ -222,12 +186,18 @@ func NewClient(cfg Config) (*Client, error) {
222186
if port := uint16(settings.GetInt(settings.PeerManualPortKey)); port != 0 {
223187
slog.Info("peer client using manual port forward",
224188
"port", port, "source", "setting")
225-
return &manualPortForwarder{port: port}, nil
189+
return portforward.NewManualForwarder(port), nil
226190
}
227-
if p := manualPort(); p != 0 {
228-
slog.Info("peer client using manual port forward",
229-
"port", p, "source", env.PeerExternalPort.String())
230-
return &manualPortForwarder{port: p}, nil
191+
if raw := env.GetString(env.PeerExternalPort); raw != "" {
192+
port, err := portforward.ParseManualPort(raw)
193+
if err != nil {
194+
slog.Warn("ignoring invalid "+env.PeerExternalPort.String(),
195+
"value", raw, "error", err)
196+
} else {
197+
slog.Info("peer client using manual port forward",
198+
"port", port, "source", env.PeerExternalPort.String())
199+
return portforward.NewManualForwarder(port), nil
200+
}
231201
}
232202
// Explicitly return a nil interface on error — `return
233203
// 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 satisfies the portForwarder contract without talking
10+
// to a UPnP gateway. The user is expected to have configured a port
11+
// forward on their router by hand (single-port 1:1 NAT — every consumer
12+
// router exposes port forwarding as a single port number) and pointed
13+
// peer.Client at it via setting or env var.
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). UPnP-based Forwarder fails in those environments.
18+
type ManualForwarder struct {
19+
port uint16
20+
}
21+
22+
// NewManualForwarder builds a ManualForwarder for a pre-configured router
23+
// port forward. port must be a valid TCP port; callers should obtain it
24+
// from ParseManualPort (env-var path) or from a setting that already
25+
// constrains the value to uint16.
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)