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