fix: add domain resolve
Kim committed
Mar 30, 2026 at 20:59 UTC
329de075f806559c6c77e7c2d447c621d8e44140
8 files changed
+111
-3
.env.example
+1
@@ -2,6 +2,7 @@
2
PORTAL_URL=https://localhost:4017
3
BOOTSTRAPS=https://localhost:4017
4
DISCOVERY=true
5
+WIREGUARD_ENDPOINT=
6
7
# Listener ports
8
API_PORT=4017
cmd/relay-server/main.go
+3
@@ -41,6 +41,7 @@ type relayServerConfig struct {
41
OwnerPrivateKey string
42
WireGuardPrivateKey string
43
DiscoveryPort int
44
+ WireGuardEndpoint string
45
AdminSecretKey string
46
TrustProxyHeaders bool
47
TrustedProxyCIDRs string
@@ -69,6 +70,7 @@ func runServeCommand(args []string) error {
70
utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
71
utils.StringFlagEnv(fs, &cfg.WireGuardPrivateKey, "wireguard-private-key", "", "wireguard private key for relay peer overlay", "WIREGUARD_PRIVATE_KEY")
72
utils.IntFlagEnv(fs, &cfg.DiscoveryPort, "discovery-port", 0, utils.ParsePortNumber, "public UDP listen port advertised for relay-peer discovery overlay (defaults to 51820 when wireguard is enabled)", "DISCOVERY_PORT")
73
+ utils.StringFlagEnv(fs, &cfg.WireGuardEndpoint, "wireguard-endpoint", "", "explicit public WireGuard endpoint advertised for relay peer overlay (host:port or ip:port); defaults to PORTAL_URL host + DISCOVERY_PORT when empty", "WIREGUARD_ENDPOINT")
74
utils.StringFlagEnv(fs, &cfg.AdminSecretKey, "admin-secret-key", "", "admin auth secret", "ADMIN_SECRET_KEY")
75
utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
76
utils.StringFlagEnv(fs, &cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", "", "trusted proxy CIDR allowlist for forwarded headers, comma-separated; defaults to private/loopback proxy ranges when trust-proxy-headers is enabled", "TRUSTED_PROXY_CIDRS")
@@ -122,6 +124,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
124
Bootstraps: bootstraps,
125
WireGuardPrivateKey: cfg.WireGuardPrivateKey,
126
DiscoveryPort: cfg.DiscoveryPort,
127
+ WireGuardEndpoint: cfg.WireGuardEndpoint,
128
ACME: acme.Config{
129
KeyDir: cfg.KeylessDir,
130
DNSProvider: cfg.ACMEDNSProvider,
docker-compose.yml
+1
@@ -19,6 +19,7 @@ services:
19
DISCOVERY: ${DISCOVERY:-true}
20
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
21
DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
22
+ WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
23
24
# Listener ports (published to the host below)
25
API_PORT: ${API_PORT:-4017}
docs/deployment.md
+3
@@ -105,6 +105,7 @@ Example:
105
PORTAL_URL=https://example.com
106
BOOTSTRAPS=
107
DISCOVERY=true
108
+WIREGUARD_ENDPOINT=
109
SNI_PORT=443
110
ADMIN_SECRET_KEY=your-admin-secret
111
KEYLESS_DIR=./.portal-certs
@@ -129,6 +130,8 @@ Notes:
130
131
- For non-apex deployments, set `PORTAL_URL` to the non-apex host value, for example `https://portal.example.com:8443`
132
- Portal uses the `PORTAL_URL` host for public lease hostnames
133
+- `WIREGUARD_ENDPOINT` is optional. When empty, Portal advertises `PORTAL_URL` host with `DISCOVERY_PORT`
134
+- Set `WIREGUARD_ENDPOINT` explicitly only when relay-peer discovery UDP is exposed on a different address than `PORTAL_URL`
135
- `KEYLESS_DIR` stores relay certificate material
136
137
If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use the original client IP, set:
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+1
@@ -72,6 +72,7 @@ services:
72
SNI_PORT: ${SNI_PORT:-4443}
73
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
74
DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
75
+ WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
76
UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
77
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
78
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
docs/examples/nginx-proxy/docker-compose.yaml
+1
@@ -66,6 +66,7 @@ services:
66
SNI_PORT: ${SNI_PORT:-4443}
67
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
68
DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
69
+ WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
70
71
# UDP transport (0 = disabled, set count to enable).
72
UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
portal/wireguard/stack.go
+60
-3
@@ -9,6 +9,7 @@ import (
9
"strconv"
10
"strings"
11
"sync"
12
+ "time"
13
14
"golang.zx2c4.com/wireguard/conn"
15
"golang.zx2c4.com/wireguard/device"
@@ -23,6 +24,7 @@ const (
24
DefaultListenPort = 51820
25
DefaultPeerAPIHTTPPort = 7777
26
DefaultPersistentKeepalive = 25
27
+ defaultEndpointResolveTTL = 3 * time.Second
28
)
29
30
type stack struct {
@@ -124,18 +126,29 @@ func (s *stack) ApplyPeers(peers []types.DesiredPeer) error {
126
127
var builder strings.Builder
128
builder.WriteString("replace_peers=true\n")
129
+ var warnErr error
130
131
for _, peer := range peers {
132
publicKeyHex, err := utils.WireGuardKeyHex(peer.WireGuardPublicKey)
133
if err != nil {
134
return fmt.Errorf("normalize peer %q public key: %w", peer.RelayID, err)
135
}
136
+
137
+ resolvedEndpoint := ""
138
+ if endpoint := strings.TrimSpace(peer.WireGuardEndpoint); endpoint != "" {
139
+ resolvedEndpoint, err = resolvePeerEndpoint(endpoint)
140
+ if err != nil {
141
+ warnErr = errors.Join(warnErr, fmt.Errorf("resolve peer %q endpoint: %w", peer.RelayID, err))
142
+ continue
143
+ }
144
+ }
145
+
146
builder.WriteString("public_key=")
147
builder.WriteString(publicKeyHex)
148
builder.WriteByte('\n')
136
- if endpoint := strings.TrimSpace(peer.WireGuardEndpoint); endpoint != "" {
149
+ if resolvedEndpoint != "" {
150
builder.WriteString("endpoint=")
138
- builder.WriteString(endpoint)
151
+ builder.WriteString(resolvedEndpoint)
152
builder.WriteByte('\n')
153
}
154
@@ -152,7 +165,51 @@ func (s *stack) ApplyPeers(peers []types.DesiredPeer) error {
165
}
166
}
167
155
- return s.device.IpcSet(builder.String())
168
+ if err := s.device.IpcSet(builder.String()); err != nil {
169
+ return err
170
+ }
171
+ return warnErr
172
+}
173
+
174
+func resolvePeerEndpoint(raw string) (string, error) {
175
+ endpoint := strings.TrimSpace(raw)
176
+ if endpoint == "" {
177
+ return "", errors.New("wireguard endpoint is required")
178
+ }
179
+
180
+ host, port, err := net.SplitHostPort(endpoint)
181
+ if err != nil {
182
+ return "", err
183
+ }
184
+
185
+ host = strings.Trim(host, "[]")
186
+ if host == "" {
187
+ return "", errors.New("wireguard endpoint host is required")
188
+ }
189
+
190
+ if ip, err := netip.ParseAddr(host); err == nil {
191
+ return net.JoinHostPort(ip.String(), port), nil
192
+ }
193
+
194
+ ctx, cancel := context.WithTimeout(context.Background(), defaultEndpointResolveTTL)
195
+ defer cancel()
196
+
197
+ addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
198
+ if err != nil {
199
+ return "", fmt.Errorf("lookup %q: %w", host, err)
200
+ }
201
+ if len(addrs) == 0 {
202
+ return "", fmt.Errorf("lookup %q: no IP addresses found", host)
203
+ }
204
+
205
+ selected := addrs[0]
206
+ for _, addr := range addrs {
207
+ if addr.Is4() {
208
+ selected = addr
209
+ break
210
+ }
211
+ }
212
+ return net.JoinHostPort(selected.String(), port), nil
213
}
214
215
func (s *stack) Close() error {
portal/wireguard/stack_test.go
+41
@@ -3,6 +3,7 @@ package wireguard
3
import (
4
"encoding/base64"
5
"net"
6
+ "strings"
7
"testing"
8
9
"github.com/gosuda/portal/v2/utils"
@@ -56,6 +57,46 @@ func TestStackStartAndClose(t *testing.T) {
57
})
58
}
59
60
+func TestResolvePeerEndpointPreservesIPLiteral(t *testing.T) {
61
+ t.Parallel()
62
+
63
+ got, err := resolvePeerEndpoint("127.0.0.1:51820")
64
+ if err != nil {
65
+ t.Fatalf("resolvePeerEndpoint() error = %v", err)
66
+ }
67
+ if got != "127.0.0.1:51820" {
68
+ t.Fatalf("resolvePeerEndpoint() = %q, want %q", got, "127.0.0.1:51820")
69
+ }
70
+}
71
+
72
+func TestResolvePeerEndpointResolvesHostname(t *testing.T) {
73
+ t.Parallel()
74
+
75
+ got, err := resolvePeerEndpoint("localhost:51820")
76
+ if err != nil {
77
+ t.Fatalf("resolvePeerEndpoint() error = %v", err)
78
+ }
79
+
80
+ host, port, err := net.SplitHostPort(got)
81
+ if err != nil {
82
+ t.Fatalf("SplitHostPort() error = %v", err)
83
+ }
84
+ if port != "51820" {
85
+ t.Fatalf("port = %q, want %q", port, "51820")
86
+ }
87
+ if strings.EqualFold(host, "localhost") {
88
+ t.Fatalf("host = %q, want resolved IP literal", host)
89
+ }
90
+
91
+ ip := net.ParseIP(host)
92
+ if ip == nil {
93
+ t.Fatalf("host = %q, want valid IP literal", host)
94
+ }
95
+ if !ip.IsLoopback() {
96
+ t.Fatalf("host = %q, want loopback IP", host)
97
+ }
98
+}
99
+
100
func reserveUDPPort(t *testing.T) string {
101
t.Helper()
102