feat: implement wireguard
Kim committed
Mar 26, 2026 at 19:07 UTC
999b8687d43599b891509da239d775eeab7c125e
21 files changed
+2049
-188
cmd/relay-server/main.go
+31
-23
@@ -31,26 +31,28 @@ func main() {
31
}
32
33
type relayServerConfig struct {
34
- PortalURL string
35
- APIPort int
36
- SNIPort int
37
- UDPPortCount int
38
- LandingPageEnabled bool
39
- Bootstraps string
40
- DiscoveryEnabled bool
41
- OwnerPrivateKey string
42
- AdminSecretKey string
43
- TrustProxyHeaders bool
44
- TrustedProxyCIDRs string
45
- KeylessDir string
46
- AdminSettingsPath string
47
- ACMEDNSProvider string
48
- CloudflareToken string
49
- AWSAccessKeyID string
50
- AWSSecretAccessKey string
51
- AWSSessionToken string
52
- AWSRegion string
53
- AWSHostedZoneID string
34
+ PortalURL string
35
+ APIPort int
36
+ SNIPort int
37
+ UDPPortCount int
38
+ LandingPageEnabled bool
39
+ Bootstraps string
40
+ DiscoveryEnabled bool
41
+ OwnerPrivateKey string
42
+ WireGuardPrivateKey string
43
+ DiscoveryPort int
44
+ AdminSecretKey string
45
+ TrustProxyHeaders bool
46
+ TrustedProxyCIDRs string
47
+ KeylessDir string
48
+ AdminSettingsPath string
49
+ ACMEDNSProvider string
50
+ CloudflareToken string
51
+ AWSAccessKeyID string
52
+ AWSSecretAccessKey string
53
+ AWSSessionToken string
54
+ AWSRegion string
55
+ AWSHostedZoneID string
56
}
57
58
func runServeCommand(args []string) error {
@@ -65,6 +67,8 @@ func runServeCommand(args []string) error {
67
utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
68
utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
69
utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
70
+ utils.StringFlagEnv(fs, &cfg.WireGuardPrivateKey, "wireguard-private-key", "", "wireguard private key for relay peer overlay", "WIREGUARD_PRIVATE_KEY")
71
+ 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")
72
utils.StringFlagEnv(fs, &cfg.AdminSecretKey, "admin-secret-key", "", "admin auth secret", "ADMIN_SECRET_KEY")
73
utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
74
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")
@@ -96,6 +100,7 @@ func runServeCommand(args []string) error {
100
Str("admin_settings_path", cfg.AdminSettingsPath).
101
Bool("landing_page_enabled", cfg.LandingPageEnabled).
102
Bool("discovery_enabled", cfg.DiscoveryEnabled).
103
+ Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
104
Bool("udp_enabled", cfg.UDPPortCount > 0).
105
Msg("configured relay server")
106
@@ -112,9 +117,11 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
117
}
118
119
server, err := portal.NewServer(portal.ServerConfig{
115
- PortalURL: cfg.PortalURL,
116
- OwnerPrivateKey: cfg.OwnerPrivateKey,
117
- Bootstraps: bootstraps,
120
+ PortalURL: cfg.PortalURL,
121
+ OwnerPrivateKey: cfg.OwnerPrivateKey,
122
+ Bootstraps: bootstraps,
123
+ WireGuardPrivateKey: cfg.WireGuardPrivateKey,
124
+ DiscoveryPort: cfg.DiscoveryPort,
125
ACME: acme.Config{
126
KeyDir: cfg.KeylessDir,
127
DNSProvider: cfg.ACMEDNSProvider,
@@ -152,6 +159,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
159
Str("root_host", rootHost).
160
Str("acme_dns_provider", cfg.ACMEDNSProvider).
161
Bool("discovery_enabled", server.DiscoveryEnabled()).
162
+ Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
163
Bool("udp_enabled", cfg.UDPPortCount > 0).
164
Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1")
165
if quicAddr := server.QUICTunnelAddr(); quicAddr != "" {
docker-compose.yml
+3
@@ -8,6 +8,7 @@ services:
8
ports:
9
- "${API_PORT:-4017}:${API_PORT:-4017}"
10
- "${SNI_PORT:-443}:${SNI_PORT:-443}"
11
+ - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
12
# Uncomment below when enabling UDP transport (UDP_PORT_COUNT > 0):
13
# - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
14
# - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
@@ -16,6 +17,8 @@ services:
17
PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
18
BOOTSTRAPS: ${BOOTSTRAPS:-}
19
DISCOVERY: ${DISCOVERY:-true}
20
+ WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
21
+ DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
22
23
# Listener ports (published to the host below)
24
API_PORT: ${API_PORT:-4017}
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+4
@@ -53,6 +53,7 @@ services:
53
# NAT-traversal relay server.
54
# TCP (4017, 4443) is reached by nginx via host.docker.internal.
55
# SNI_PORT is 4443 to avoid conflicting with nginx on 443.
56
+ # If you enable relay-peer discovery overlay, expose DISCOVERY_PORT/udp as well.
57
# If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
58
portal:
59
image: ghcr.io/gosuda/portal:latest
@@ -60,6 +61,7 @@ services:
61
ports:
62
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
63
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
64
+ - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
65
# Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
66
# - "443:${SNI_PORT:-4443}/udp"
67
# - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
@@ -68,6 +70,8 @@ services:
70
PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
71
API_PORT: ${API_PORT:-4017}
72
SNI_PORT: ${SNI_PORT:-4443}
73
+ WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
74
+ DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
75
UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
76
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
77
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
docs/examples/nginx-proxy/docker-compose.yaml
+4
@@ -44,6 +44,7 @@ services:
44
# NAT-traversal relay server.
45
# TCP (4017, 4443) is reached by nginx via 127.0.0.1.
46
# SNI_PORT is set to 4443 to avoid conflicting with nginx on port 443.
47
+ # If you enable relay-peer discovery overlay, expose DISCOVERY_PORT/udp as well.
48
# If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
49
portal:
50
image: ghcr.io/gosuda/portal:latest
@@ -51,6 +52,7 @@ services:
52
ports:
53
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
54
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
55
+ - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
56
# Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
57
# - "443:${SNI_PORT:-4443}/udp"
58
# - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
@@ -62,6 +64,8 @@ services:
64
API_PORT: ${API_PORT:-4017}
65
# Use a non-443 port to avoid conflict with nginx on the host.
66
SNI_PORT: ${SNI_PORT:-4443}
67
+ WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
68
+ DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
69
70
# UDP transport (0 = disabled, set count to enable).
71
UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
frontend/src/components/LandingHero.tsx
+1
-1
@@ -40,7 +40,7 @@ const heroFeatures = [
40
{
41
title: "End-to-end TLS",
42
description:
43
- "Traffic is routed via SNI with keyless TLS, while TLS still terminates on your app.",
43
+ "End-to-end TLS via SNI routing, keyless TLS, and built-in MITM detection.",
44
},
45
{
46
title: "Permissionless hosting",
go.mod
+7
@@ -15,6 +15,7 @@ require (
15
golang.org/x/crypto v0.48.0
16
golang.org/x/net v0.50.0
17
golang.org/x/sync v0.19.0
18
+ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
19
)
20
21
require (
@@ -31,11 +32,17 @@ require (
32
github.com/aws/smithy-go v1.24.0 // indirect
33
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
34
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
35
+ github.com/google/btree v1.1.2 // indirect
36
github.com/mattn/go-colorable v0.1.13 // indirect
37
github.com/mattn/go-isatty v0.0.20 // indirect
38
github.com/miekg/dns v1.1.72 // indirect
39
golang.org/x/mod v0.32.0 // indirect
40
golang.org/x/sys v0.41.0 // indirect
41
golang.org/x/text v0.34.0 // indirect
42
+ golang.org/x/time v0.14.0 // indirect
43
golang.org/x/tools v0.41.0 // indirect
44
+ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
45
+ gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
46
)
47
+
48
+exclude golang.zx2c4.com/wireguard/tun/netstack v0.0.0-20220703234212-c31a7b1ab478
go.sum
+11
@@ -33,6 +33,7 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9
33
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
34
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
35
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
36
+github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
37
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
38
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
39
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
@@ -41,6 +42,8 @@ github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91Uro
42
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
43
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
44
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
45
+github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
46
+github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
47
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
48
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
49
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc h1:aS9LQ35x6EtrGKCmOWRj6Y9aQ2l5hP8dVva4oxB9VEg=
@@ -80,7 +83,15 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
83
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
84
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
85
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
86
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
87
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
88
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
89
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
90
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
91
+golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
92
+golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
93
+golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
94
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
95
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
96
+gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
97
+gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
portal/api_server.go
+91
-7
@@ -80,7 +80,7 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
80
base.ServeHTTP(w, r)
81
return
82
}
83
- discovery.ServeHTTP(w, r, []string{s.cfg.PortalURL}, s.discoveryBootstrapsSnapshot(), s.discover)
83
+ discovery.ServeHTTP(w, r, s.discover)
84
case types.PathV1Sign:
85
if keylessSignerHandler == nil {
86
http.NotFound(w, r)
@@ -105,8 +105,15 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
105
}
106
107
func (s *Server) discover(_ context.Context, req types.DiscoverRequest) (types.DiscoverResponse, error) {
108
+ self, err := s.discoverySelfDescriptor()
109
+ if err != nil {
110
+ return types.DiscoverResponse{}, err
111
+ }
112
resp := types.DiscoverResponse{
109
- OwnerAddress: s.ownerIdentity.Address,
113
+ ProtocolVersion: 1,
114
+ GeneratedAt: time.Now().UTC(),
115
+ Self: self,
116
+ Peers: s.discoveryAdvertisedPeerDescriptors(nil),
117
}
118
if req.RootHost != "" && req.RootHost != s.rootHost {
119
return resp, nil
@@ -145,14 +152,85 @@ func (s *Server) discover(_ context.Context, req types.DiscoverRequest) (types.D
152
ownerAddress = s.ownerIdentity.Address
153
}
154
148
- return types.DiscoverResponse{
155
+ resp.Peers = s.discoveryAdvertisedPeerDescriptors(lease.Bootstraps)
156
+ resp.Service = &types.DiscoveredService{
157
Found: true,
158
Name: lease.Name,
159
Hostname: lease.Hostname,
160
ExpiresAt: lease.ExpiresAt,
161
OwnerAddress: ownerAddress,
154
- Bootstraps: lease.Bootstraps,
155
- }, nil
162
+ RelayID: self.RelayID,
163
+ }
164
+ return resp, nil
165
+}
166
+
167
+func (s *Server) discoverySelfDescriptor() (types.RelayDescriptor, error) {
168
+ now := time.Now().UTC()
169
+ ingressAddr := s.rootHost
170
+ if s.cfg.SNIPort != 0 && s.cfg.SNIPort != 443 {
171
+ ingressAddr = fmt.Sprintf("%s:%d", ingressAddr, s.cfg.SNIPort)
172
+ }
173
+
174
+ supportsOverlayPeer := strings.TrimSpace(s.cfg.WireGuardPublicKey) != "" &&
175
+ strings.TrimSpace(s.cfg.WireGuardEndpoint) != "" &&
176
+ strings.TrimSpace(s.cfg.OverlayIPv4) != ""
177
+
178
+ descriptor := types.RelayDescriptor{
179
+ RelayID: s.cfg.PortalURL,
180
+ OwnerAddress: s.ownerIdentity.Address,
181
+ SignerPublicKey: s.ownerIdentity.PublicKey,
182
+ Sequence: uint64(now.UnixMilli()),
183
+ Version: 1,
184
+ IssuedAt: now,
185
+ ExpiresAt: now.Add(2 * defaultDiscoveryInterval),
186
+ APIHTTPSAddr: s.cfg.PortalURL,
187
+ IngressTLSAddr: ingressAddr,
188
+ SupportsTCP: true,
189
+ SupportsUDP: s.cfg.UDPPortCount > 0,
190
+ SupportsOverlayPeer: supportsOverlayPeer,
191
+ SupportsWitness: false,
192
+ SupportsVPNExit: false,
193
+ StatusState: "healthy",
194
+ }
195
+ if supportsOverlayPeer {
196
+ descriptor.WireGuardPublicKey = strings.TrimSpace(s.cfg.WireGuardPublicKey)
197
+ descriptor.WireGuardEndpoint = strings.TrimSpace(s.cfg.WireGuardEndpoint)
198
+ descriptor.OverlayIPv4 = strings.TrimSpace(s.cfg.OverlayIPv4)
199
+ descriptor.OverlayCIDRs = append([]string(nil), s.cfg.OverlayCIDRs...)
200
+ }
201
+ return discovery.SignedDescriptor(descriptor, s.ownerIdentity.PrivateKey)
202
+}
203
+
204
+func (s *Server) discoveryAdvertisedPeerDescriptors(urls []string) []types.RelayDescriptor {
205
+ advertised := s.discoveryCache.AdvertisedDescriptors()
206
+ if len(advertised) == 0 {
207
+ return nil
208
+ }
209
+ if len(urls) == 0 {
210
+ return advertised
211
+ }
212
+
213
+ allowed := make(map[string]struct{}, len(urls))
214
+ for _, relayURL := range urls {
215
+ relayURL = strings.TrimSpace(relayURL)
216
+ if relayURL == "" {
217
+ continue
218
+ }
219
+ normalized, err := utils.NormalizeRelayURL(relayURL)
220
+ if err != nil {
221
+ continue
222
+ }
223
+ allowed[normalized] = struct{}{}
224
+ }
225
+
226
+ records := make([]types.RelayDescriptor, 0, len(advertised))
227
+ for _, descriptor := range advertised {
228
+ if _, ok := allowed[descriptor.APIHTTPSAddr]; !ok {
229
+ continue
230
+ }
231
+ records = append(records, descriptor)
232
+ }
233
+ return records
234
}
235
236
func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
@@ -520,7 +598,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
598
return types.RegisterResponse{}, err
599
}
600
if s.DiscoveryEnabled() {
523
- if _, err := s.mergeDiscoveryBootstraps(bootstraps); err != nil {
601
+ if _, err := s.discoveryCache.UpsertSeedURLs(bootstraps); err != nil {
602
record.Close()
603
_, _ = s.registry.Unregister(record.ID, record.ReverseToken)
604
return types.RegisterResponse{}, err
@@ -534,7 +612,13 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
612
return types.RegisterResponse{}, err
613
}
614
if s.DiscoveryEnabled() {
537
- responseBootstraps, err = utils.NormalizeRelayURLs(append(responseBootstraps, s.discoveryBootstrapsSnapshot()...)...)
615
+ advertisedURLs, relayURLErr := discovery.RelayAPIURLs(s.discoveryCache.AdvertisedDescriptors())
616
+ if relayURLErr != nil {
617
+ record.Close()
618
+ _, _ = s.registry.Unregister(record.ID, record.ReverseToken)
619
+ return types.RegisterResponse{}, relayURLErr
620
+ }
621
+ responseBootstraps, err = utils.NormalizeRelayURLs(append(responseBootstraps, advertisedURLs...)...)
622
} else {
623
responseBootstraps, err = utils.NormalizeRelayURLs(append(responseBootstraps, append(s.cfg.Bootstraps, record.Bootstraps...)...)...)
624
}
portal/discovery/descriptor.go
new
+299
@@ -0,0 +1,299 @@
1
+package discovery
2
+
3
+import (
4
+ "crypto/sha256"
5
+ "encoding/base64"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "net"
11
+ "sort"
12
+ "strconv"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/decred/dcrd/dcrec/secp256k1/v4"
17
+ secp256k1ecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
18
+
19
+ "github.com/gosuda/portal/v2/types"
20
+ "github.com/gosuda/portal/v2/utils"
21
+)
22
+
23
+func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, error) {
24
+ desc.RelayID = strings.TrimSpace(desc.RelayID)
25
+ desc.OwnerAddress = strings.TrimSpace(desc.OwnerAddress)
26
+ desc.SignerPublicKey = strings.ToLower(strings.TrimSpace(desc.SignerPublicKey))
27
+ desc.APIHTTPSAddr = strings.TrimSpace(desc.APIHTTPSAddr)
28
+ desc.IngressTLSAddr = strings.TrimSpace(desc.IngressTLSAddr)
29
+ desc.WireGuardPublicKey = strings.TrimSpace(desc.WireGuardPublicKey)
30
+ desc.WireGuardEndpoint = strings.TrimSpace(desc.WireGuardEndpoint)
31
+ desc.OverlayIPv4 = strings.TrimSpace(desc.OverlayIPv4)
32
+ desc.StatusState = strings.TrimSpace(desc.StatusState)
33
+ desc.Region = strings.TrimSpace(desc.Region)
34
+ desc.Country = strings.TrimSpace(desc.Country)
35
+ desc.DescriptorSignature = strings.ToLower(strings.TrimSpace(desc.DescriptorSignature))
36
+ if !desc.IssuedAt.IsZero() {
37
+ desc.IssuedAt = desc.IssuedAt.UTC()
38
+ }
39
+ if !desc.ExpiresAt.IsZero() {
40
+ desc.ExpiresAt = desc.ExpiresAt.UTC()
41
+ }
42
+ if !desc.LastMITMDetectedAt.IsZero() {
43
+ desc.LastMITMDetectedAt = desc.LastMITMDetectedAt.UTC()
44
+ }
45
+
46
+ if desc.APIHTTPSAddr != "" {
47
+ normalized, err := utils.NormalizeRelayURL(desc.APIHTTPSAddr)
48
+ if err != nil {
49
+ return types.RelayDescriptor{}, fmt.Errorf("normalize api https addr: %w", err)
50
+ }
51
+ desc.APIHTTPSAddr = normalized
52
+ if desc.RelayID == "" {
53
+ desc.RelayID = normalized
54
+ }
55
+ }
56
+ if desc.OwnerAddress != "" {
57
+ address, err := NormalizeEVMAddress(desc.OwnerAddress)
58
+ if err != nil {
59
+ return types.RelayDescriptor{}, fmt.Errorf("normalize owner address: %w", err)
60
+ }
61
+ desc.OwnerAddress = address
62
+ }
63
+ if len(desc.OverlayCIDRs) > 0 {
64
+ normalized, err := NormalizeOverlayCIDRs(desc.OverlayCIDRs)
65
+ if err != nil {
66
+ return types.RelayDescriptor{}, err
67
+ }
68
+ desc.OverlayCIDRs = normalized
69
+ }
70
+
71
+ if !desc.SupportsOverlayPeer {
72
+ desc.WireGuardPublicKey = ""
73
+ desc.WireGuardEndpoint = ""
74
+ desc.OverlayIPv4 = ""
75
+ desc.OverlayCIDRs = nil
76
+ }
77
+
78
+ return desc, nil
79
+}
80
+
81
+func CanonicalDescriptorPayload(desc types.RelayDescriptor) ([]byte, error) {
82
+ normalized, err := NormalizeDescriptor(desc)
83
+ if err != nil {
84
+ return nil, err
85
+ }
86
+ normalized.DescriptorSignature = ""
87
+ return json.Marshal(normalized)
88
+}
89
+
90
+func SignDescriptor(desc types.RelayDescriptor, privateKeyHex string) (string, error) {
91
+ keyHex := strings.TrimSpace(privateKeyHex)
92
+ if keyHex == "" {
93
+ return "", errors.New("private key is required")
94
+ }
95
+ if strings.HasPrefix(strings.ToLower(keyHex), "0x") {
96
+ keyHex = keyHex[2:]
97
+ }
98
+ decoded, err := hex.DecodeString(keyHex)
99
+ if err != nil {
100
+ return "", errors.New("private key must be hex encoded")
101
+ }
102
+ if len(decoded) != secp256k1.PrivKeyBytesLen {
103
+ return "", fmt.Errorf("private key must be %d bytes", secp256k1.PrivKeyBytesLen)
104
+ }
105
+
106
+ payload, err := CanonicalDescriptorPayload(desc)
107
+ if err != nil {
108
+ return "", err
109
+ }
110
+ hash := sha256.Sum256(payload)
111
+ privateKey := secp256k1.PrivKeyFromBytes(decoded)
112
+ signature := secp256k1ecdsa.Sign(privateKey, hash[:])
113
+ return hex.EncodeToString(signature.Serialize()), nil
114
+}
115
+
116
+func SignedDescriptor(desc types.RelayDescriptor, privateKeyHex string) (types.RelayDescriptor, error) {
117
+ normalized, err := NormalizeDescriptor(desc)
118
+ if err != nil {
119
+ return types.RelayDescriptor{}, err
120
+ }
121
+ signature, err := SignDescriptor(normalized, privateKeyHex)
122
+ if err != nil {
123
+ return types.RelayDescriptor{}, err
124
+ }
125
+ normalized.DescriptorSignature = signature
126
+ return normalized, nil
127
+}
128
+
129
+func VerifyDescriptor(desc types.RelayDescriptor) error {
130
+ normalized, err := NormalizeDescriptor(desc)
131
+ if err != nil {
132
+ return err
133
+ }
134
+ if normalized.DescriptorSignature == "" {
135
+ return errors.New("descriptor signature is required")
136
+ }
137
+ if normalized.SignerPublicKey == "" {
138
+ return errors.New("signer public key is required")
139
+ }
140
+
141
+ pubKeyBytes, err := hex.DecodeString(normalized.SignerPublicKey)
142
+ if err != nil {
143
+ return errors.New("signer public key must be hex encoded")
144
+ }
145
+ pubKey, err := secp256k1.ParsePubKey(pubKeyBytes)
146
+ if err != nil {
147
+ return errors.New("invalid secp256k1 signer public key")
148
+ }
149
+
150
+ sigBytes, err := hex.DecodeString(normalized.DescriptorSignature)
151
+ if err != nil {
152
+ return errors.New("descriptor signature must be hex encoded")
153
+ }
154
+ signature, err := secp256k1ecdsa.ParseDERSignature(sigBytes)
155
+ if err != nil {
156
+ return fmt.Errorf("parse descriptor signature: %w", err)
157
+ }
158
+
159
+ payload, err := CanonicalDescriptorPayload(normalized)
160
+ if err != nil {
161
+ return err
162
+ }
163
+ hash := sha256.Sum256(payload)
164
+ if !signature.Verify(hash[:], pubKey) {
165
+ return errors.New("descriptor signature is invalid")
166
+ }
167
+ return nil
168
+}
169
+
170
+func ValidateDescriptor(desc types.RelayDescriptor, now time.Time) (types.RelayDescriptor, error) {
171
+ normalized, err := NormalizeDescriptor(desc)
172
+ if err != nil {
173
+ return types.RelayDescriptor{}, err
174
+ }
175
+ if now.IsZero() {
176
+ now = time.Now()
177
+ }
178
+ now = now.UTC()
179
+
180
+ switch {
181
+ case normalized.RelayID == "":
182
+ return types.RelayDescriptor{}, errors.New("relay_id is required")
183
+ case normalized.OwnerAddress == "":
184
+ return types.RelayDescriptor{}, errors.New("owner_address is required")
185
+ case normalized.SignerPublicKey == "":
186
+ return types.RelayDescriptor{}, errors.New("signer_public_key is required")
187
+ case normalized.APIHTTPSAddr == "":
188
+ return types.RelayDescriptor{}, errors.New("api_https_addr is required")
189
+ case normalized.Sequence == 0:
190
+ return types.RelayDescriptor{}, errors.New("sequence is required")
191
+ case normalized.Version == 0:
192
+ return types.RelayDescriptor{}, errors.New("version is required")
193
+ case normalized.IssuedAt.IsZero():
194
+ return types.RelayDescriptor{}, errors.New("issued_at is required")
195
+ case normalized.ExpiresAt.IsZero():
196
+ return types.RelayDescriptor{}, errors.New("expires_at is required")
197
+ case normalized.ExpiresAt.Before(now):
198
+ return types.RelayDescriptor{}, errors.New("descriptor expired")
199
+ case normalized.IssuedAt.After(normalized.ExpiresAt):
200
+ return types.RelayDescriptor{}, errors.New("issued_at must be before expires_at")
201
+ }
202
+
203
+ derivedOwnerAddress, err := AddressFromCompressedPublicKeyHex(normalized.SignerPublicKey)
204
+ if err != nil {
205
+ return types.RelayDescriptor{}, err
206
+ }
207
+ if normalized.OwnerAddress != derivedOwnerAddress {
208
+ return types.RelayDescriptor{}, errors.New("owner_address does not match signer_public_key")
209
+ }
210
+
211
+ if normalized.SupportsOverlayPeer {
212
+ if err := ValidateWireGuardPublicKey(normalized.WireGuardPublicKey); err != nil {
213
+ return types.RelayDescriptor{}, err
214
+ }
215
+ if err := ValidateWireGuardEndpoint(normalized.WireGuardEndpoint); err != nil {
216
+ return types.RelayDescriptor{}, err
217
+ }
218
+ if err := ValidateOverlayIPv4(normalized.OverlayIPv4); err != nil {
219
+ return types.RelayDescriptor{}, err
220
+ }
221
+ }
222
+
223
+ if err := VerifyDescriptor(normalized); err != nil {
224
+ return types.RelayDescriptor{}, err
225
+ }
226
+ return normalized, nil
227
+}
228
+
229
+func ValidateWireGuardPublicKey(raw string) error {
230
+ key := strings.TrimSpace(raw)
231
+ if key == "" {
232
+ return errors.New("wireguard_public_key is required")
233
+ }
234
+ decoded, err := base64.StdEncoding.DecodeString(key)
235
+ if err != nil {
236
+ return errors.New("wireguard_public_key must be base64 encoded")
237
+ }
238
+ if len(decoded) != 32 {
239
+ return errors.New("wireguard_public_key must be 32 bytes")
240
+ }
241
+ return nil
242
+}
243
+
244
+func ValidateWireGuardEndpoint(raw string) error {
245
+ endpoint := strings.TrimSpace(raw)
246
+ if endpoint == "" {
247
+ return errors.New("wireguard_endpoint is required")
248
+ }
249
+ host, port, err := net.SplitHostPort(endpoint)
250
+ if err != nil {
251
+ return errors.New("wireguard_endpoint must be host:port")
252
+ }
253
+ if strings.TrimSpace(host) == "" {
254
+ return errors.New("wireguard_endpoint host is required")
255
+ }
256
+ portNum, err := strconv.Atoi(port)
257
+ if err != nil || portNum <= 0 || portNum > 65535 {
258
+ return errors.New("wireguard_endpoint port is invalid")
259
+ }
260
+ return nil
261
+}
262
+
263
+func ValidateOverlayIPv4(raw string) error {
264
+ ipText := strings.TrimSpace(raw)
265
+ if ipText == "" {
266
+ return errors.New("overlay_ipv4 is required")
267
+ }
268
+ ip := net.ParseIP(ipText)
269
+ if ip == nil || ip.To4() == nil {
270
+ return errors.New("overlay_ipv4 must be a valid IPv4 address")
271
+ }
272
+ return nil
273
+}
274
+
275
+func NormalizeOverlayCIDRs(inputs []string) ([]string, error) {
276
+ if len(inputs) == 0 {
277
+ return nil, nil
278
+ }
279
+ seen := make(map[string]struct{}, len(inputs))
280
+ out := make([]string, 0, len(inputs))
281
+ for _, input := range inputs {
282
+ input = strings.TrimSpace(input)
283
+ if input == "" {
284
+ continue
285
+ }
286
+ _, network, err := net.ParseCIDR(input)
287
+ if err != nil {
288
+ return nil, fmt.Errorf("invalid overlay cidr %q", input)
289
+ }
290
+ normalized := network.String()
291
+ if _, ok := seen[normalized]; ok {
292
+ continue
293
+ }
294
+ seen[normalized] = struct{}{}
295
+ out = append(out, normalized)
296
+ }
297
+ sort.Strings(out)
298
+ return out, nil
299
+}
portal/discovery/discovery.go
+66
-45
@@ -19,6 +19,54 @@ type Resolver func(context.Context, types.DiscoverRequest) (types.DiscoverRespon
19
20
const defaultRequestTimeout = 15 * time.Second
21
22
+func Discover(ctx context.Context, relayURL string, req types.DiscoverRequest, rootCAPEM []byte) (types.DiscoverResponse, error) {
23
+ return discoverPeer(ctx, relayURL, req, rootCAPEM)
24
+}
25
+
26
+func RelayAPIURLs(descriptors []types.RelayDescriptor) ([]string, error) {
27
+ urls := make([]string, 0, len(descriptors))
28
+ for _, descriptor := range descriptors {
29
+ if apiURL := strings.TrimSpace(descriptor.APIHTTPSAddr); apiURL != "" {
30
+ urls = append(urls, apiURL)
31
+ }
32
+ }
33
+ if len(urls) == 0 {
34
+ return nil, nil
35
+ }
36
+
37
+ normalized, err := utils.NormalizeRelayURLs(urls...)
38
+ if err != nil {
39
+ return nil, err
40
+ }
41
+ return utils.ExcludeLocalRelayURLs(normalized...)
42
+}
43
+
44
+func ResolvePeerResponse(resp types.DiscoverResponse, now time.Time) (types.RelayDescriptor, []types.RelayDescriptor, error) {
45
+ self, err := ValidateDescriptor(resp.Self, now)
46
+ if err != nil {
47
+ return types.RelayDescriptor{}, nil, fmt.Errorf("validate self descriptor: %w", err)
48
+ }
49
+
50
+ seen := map[string]struct{}{self.RelayID: {}}
51
+ peers := make([]types.RelayDescriptor, 0, len(resp.Peers))
52
+ var resolveErr error
53
+
54
+ for _, descriptor := range resp.Peers {
55
+ verified, err := ValidateDescriptor(descriptor, now)
56
+ if err != nil {
57
+ resolveErr = errors.Join(resolveErr, fmt.Errorf("validate peer %q: %w", descriptor.RelayID, err))
58
+ continue
59
+ }
60
+ if _, ok := seen[verified.RelayID]; ok {
61
+ continue
62
+ }
63
+ seen[verified.RelayID] = struct{}{}
64
+ peers = append(peers, verified)
65
+ }
66
+
67
+ return self, peers, resolveErr
68
+}
69
+
70
func DiscoverBootstraps(ctx context.Context, peers []string, req types.DiscoverRequest, rootCAPEM []byte) ([]string, error) {
71
peers, err := utils.ExcludeLocalRelayURLs(peers...)
72
if err != nil {
@@ -38,15 +86,25 @@ func DiscoverBootstraps(ctx context.Context, peers []string, req types.DiscoverR
86
discovered := false
87
88
for _, peer := range peers {
41
- resp, err := discoverPeer(ctx, peer, req, rootCAPEM)
89
+ resp, err := Discover(ctx, peer, req, rootCAPEM)
90
if err != nil {
91
discoverErr = errors.Join(discoverErr, fmt.Errorf("discover %q: %w", peer, err))
92
continue
93
}
94
47
- discoveredBootstraps, err := utils.ExcludeLocalRelayURLs(resp.Bootstraps...)
95
+ self, advertised, resolveErr := ResolvePeerResponse(resp, time.Now().UTC())
96
+ if strings.TrimSpace(self.RelayID) == "" {
97
+ discoverErr = errors.Join(discoverErr, fmt.Errorf("resolve %q self descriptor: %w", peer, resolveErr))
98
+ continue
99
+ }
100
+ if resolveErr != nil {
101
+ discoverErr = errors.Join(discoverErr, fmt.Errorf("resolve %q descriptors: %w", peer, resolveErr))
102
+ }
103
+
104
+ descriptors := append([]types.RelayDescriptor{self}, advertised...)
105
+ discoveredBootstraps, err := RelayAPIURLs(descriptors)
106
if err != nil {
49
- discoverErr = errors.Join(discoverErr, fmt.Errorf("filter %q bootstraps: %w", peer, err))
107
+ discoverErr = errors.Join(discoverErr, fmt.Errorf("extract %q relay urls: %w", peer, err))
108
continue
109
}
110
bootstraps, err = utils.MergeRelayURLs(bootstraps, nil, discoveredBootstraps)
@@ -60,10 +118,10 @@ func DiscoverBootstraps(ctx context.Context, peers []string, req types.DiscoverR
118
if !discovered {
119
return bootstraps, discoverErr
120
}
63
- return bootstraps, nil
121
+ return bootstraps, discoverErr
122
}
123
66
-func ServeHTTP(w http.ResponseWriter, r *http.Request, selfURLs, bootstraps []string, resolver Resolver) {
124
+func ServeHTTP(w http.ResponseWriter, r *http.Request, resolver Resolver) {
125
if r.Method != http.MethodGet {
126
utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
127
return
@@ -77,34 +135,17 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, selfURLs, bootstraps []st
135
utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
136
return
137
}
80
-
81
- resolvedBootstraps, err := buildResponseBootstraps(selfURLs, bootstraps, nil)
82
- if err != nil {
83
- utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
84
- return
85
- }
86
-
87
- resp := types.DiscoverResponse{
88
- Found: false,
89
- Bootstraps: resolvedBootstraps,
90
- }
138
if resolver == nil {
92
- utils.WriteAPIData(w, http.StatusOK, resp)
93
- return
94
- }
95
-
96
- localResp, err := resolver(r.Context(), req)
97
- if err != nil {
98
- utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
139
+ utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, "discovery resolver is not configured")
140
return
141
}
142
102
- localResp.Bootstraps, err = buildResponseBootstraps(selfURLs, bootstraps, localResp.Bootstraps)
143
+ resp, err := resolver(r.Context(), req)
144
if err != nil {
145
utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
146
return
147
}
107
- utils.WriteAPIData(w, http.StatusOK, localResp)
148
+ utils.WriteAPIData(w, http.StatusOK, resp)
149
}
150
151
func normalizeRequest(req types.DiscoverRequest) (types.DiscoverRequest, error) {
@@ -188,23 +229,3 @@ func discoverPeer(ctx context.Context, relayURL string, req types.DiscoverReques
229
}
230
return envelope.Data, nil
231
}
191
-
192
-func buildResponseBootstraps(selfURLs, bootstraps, extra []string) ([]string, error) {
193
- merged, err := utils.MergeRelayURLs(bootstraps, selfURLs, extra)
194
- if err != nil {
195
- return nil, err
196
- }
197
- if len(selfURLs) == 0 {
198
- return utils.ExcludeLocalRelayURLs(merged...)
199
- }
200
-
201
- normalizedSelf, err := utils.NormalizeRelayURLs(selfURLs...)
202
- if err != nil {
203
- return nil, fmt.Errorf("normalize self urls: %w", err)
204
- }
205
- resolvedBootstraps, err := utils.NormalizeRelayURLs(append(normalizedSelf, merged...)...)
206
- if err != nil {
207
- return nil, fmt.Errorf("normalize bootstraps: %w", err)
208
- }
209
- return utils.ExcludeLocalRelayURLs(resolvedBootstraps...)
210
-}
portal/discovery/identity.go
+35
-10
@@ -13,9 +13,41 @@ import (
13
type Identity struct {
14
Generated bool `json:"generated,omitempty"`
15
Address string `json:"address"`
16
+ PublicKey string `json:"public_key"`
17
PrivateKey string `json:"private_key"`
18
}
19
20
+func AddressFromCompressedPublicKeyHex(rawPublicKey string) (string, error) {
21
+ publicKeyHex := strings.TrimSpace(rawPublicKey)
22
+ if publicKeyHex == "" {
23
+ return "", errors.New("public key is required")
24
+ }
25
+ if strings.HasPrefix(strings.ToLower(publicKeyHex), "0x") {
26
+ publicKeyHex = publicKeyHex[2:]
27
+ }
28
+
29
+ decoded, err := hex.DecodeString(publicKeyHex)
30
+ if err != nil {
31
+ return "", errors.New("public key must be hex encoded")
32
+ }
33
+
34
+ publicKey, err := secp256k1.ParsePubKey(decoded)
35
+ if err != nil {
36
+ return "", errors.New("invalid secp256k1 public key")
37
+ }
38
+
39
+ uncompressed := publicKey.SerializeUncompressed()
40
+ if len(uncompressed) != 65 || uncompressed[0] != 0x04 {
41
+ return "", errors.New("invalid uncompressed secp256k1 public key")
42
+ }
43
+
44
+ hasher := sha3.NewLegacyKeccak256()
45
+ _, _ = hasher.Write(uncompressed[1:])
46
+ hash := hasher.Sum(nil)
47
+
48
+ return NormalizeEVMAddress("0x" + hex.EncodeToString(hash[len(hash)-20:]))
49
+}
50
+
51
func NormalizeEVMAddress(raw string) (string, error) {
52
trimmed := strings.TrimSpace(raw)
53
if trimmed == "" {
@@ -105,16 +137,8 @@ func ResolveIdentity(rawPrivateKey string) (Identity, error) {
137
return Identity{}, errors.New("invalid secp256k1 private key")
138
}
139
108
- uncompressed := privateKey.PubKey().SerializeUncompressed()
109
- if len(uncompressed) != 65 || uncompressed[0] != 0x04 {
110
- return Identity{}, errors.New("invalid uncompressed secp256k1 public key")
111
- }
112
-
113
- hasher := sha3.NewLegacyKeccak256()
114
- _, _ = hasher.Write(uncompressed[1:])
115
- hash := hasher.Sum(nil)
116
-
117
- address, err := NormalizeEVMAddress("0x" + hex.EncodeToString(hash[len(hash)-20:]))
140
+ publicKeyHex := hex.EncodeToString(privateKey.PubKey().SerializeCompressed())
141
+ address, err := AddressFromCompressedPublicKeyHex(publicKeyHex)
142
if err != nil {
143
return Identity{}, err
144
}
@@ -122,6 +146,7 @@ func ResolveIdentity(rawPrivateKey string) (Identity, error) {
146
return Identity{
147
Generated: generated,
148
Address: address,
149
+ PublicKey: publicKeyHex,
150
PrivateKey: privateKeyHex,
151
}, nil
152
}
portal/discovery/store.go
new
+320
@@ -0,0 +1,320 @@
1
+package discovery
2
+
3
+import (
4
+ "errors"
5
+ "reflect"
6
+ "sort"
7
+ "strings"
8
+ "sync"
9
+ "time"
10
+
11
+ "github.com/gosuda/portal/v2/types"
12
+ "github.com/gosuda/portal/v2/utils"
13
+)
14
+
15
+type peerRecord struct {
16
+ seedURL string
17
+ pinnedSignerPublicKey string
18
+ state types.PeerState
19
+}
20
+
21
+// Cache keeps only verified relay descriptors and bootstrap hints.
22
+// It is not a source of truth; trust comes from seed URLs plus signer pinning.
23
+type Cache struct {
24
+ mu sync.RWMutex
25
+ peers map[string]peerRecord
26
+}
27
+
28
+func (s *Cache) Lookup(relayID string) (types.PeerState, bool) {
29
+ if strings.TrimSpace(relayID) == "" {
30
+ return types.PeerState{}, false
31
+ }
32
+
33
+ s.mu.RLock()
34
+ defer s.mu.RUnlock()
35
+
36
+ record, ok := s.peers[relayID]
37
+ if !ok {
38
+ return types.PeerState{}, false
39
+ }
40
+ return record.state, true
41
+}
42
+
43
+func NewCache() *Cache {
44
+ return &Cache{
45
+ peers: make(map[string]peerRecord),
46
+ }
47
+}
48
+
49
+func SeedDescriptor(apiURL string) (types.RelayDescriptor, error) {
50
+ normalized, err := utils.NormalizeRelayURL(apiURL)
51
+ if err != nil {
52
+ return types.RelayDescriptor{}, err
53
+ }
54
+ return types.RelayDescriptor{
55
+ RelayID: normalized,
56
+ APIHTTPSAddr: normalized,
57
+ Version: 1,
58
+ }, nil
59
+}
60
+
61
+func (s *Cache) UpsertSeedURLs(inputs []string) ([]string, error) {
62
+ if len(inputs) == 0 {
63
+ return nil, nil
64
+ }
65
+
66
+ normalized, err := utils.NormalizeRelayURLs(inputs...)
67
+ if err != nil {
68
+ return nil, err
69
+ }
70
+ normalized, err = utils.ExcludeLocalRelayURLs(normalized...)
71
+ if err != nil {
72
+ return nil, err
73
+ }
74
+
75
+ now := time.Now().UTC()
76
+ added := make([]string, 0, len(normalized))
77
+
78
+ s.mu.Lock()
79
+ defer s.mu.Unlock()
80
+
81
+ for _, apiURL := range normalized {
82
+ descriptor, err := SeedDescriptor(apiURL)
83
+ if err != nil {
84
+ return nil, err
85
+ }
86
+
87
+ record, ok := s.peers[descriptor.RelayID]
88
+ if !ok {
89
+ s.peers[descriptor.RelayID] = peerRecord{
90
+ seedURL: descriptor.APIHTTPSAddr,
91
+ state: types.PeerState{
92
+ Descriptor: descriptor,
93
+ State: types.PeerStateKnown,
94
+ FirstSeenAt: now,
95
+ LastSeenAt: now,
96
+ },
97
+ }
98
+ added = append(added, descriptor.APIHTTPSAddr)
99
+ continue
100
+ }
101
+
102
+ if strings.TrimSpace(record.seedURL) == "" {
103
+ record.seedURL = descriptor.APIHTTPSAddr
104
+ }
105
+ if strings.TrimSpace(record.state.Descriptor.APIHTTPSAddr) == "" {
106
+ record.state.Descriptor.APIHTTPSAddr = descriptor.APIHTTPSAddr
107
+ }
108
+ if strings.TrimSpace(record.state.Descriptor.RelayID) == "" {
109
+ record.state.Descriptor.RelayID = descriptor.RelayID
110
+ }
111
+ record.state.LastSeenAt = now
112
+ s.peers[descriptor.RelayID] = record
113
+ }
114
+
115
+ return added, nil
116
+}
117
+
118
+func (s *Cache) Snapshot() map[string]types.PeerState {
119
+ s.mu.RLock()
120
+ defer s.mu.RUnlock()
121
+
122
+ out := make(map[string]types.PeerState, len(s.peers))
123
+ for relayID, record := range s.peers {
124
+ out[relayID] = record.state
125
+ }
126
+ return out
127
+}
128
+
129
+func (s *Cache) SeedURL(relayID string) string {
130
+ if strings.TrimSpace(relayID) == "" {
131
+ return ""
132
+ }
133
+
134
+ s.mu.RLock()
135
+ defer s.mu.RUnlock()
136
+
137
+ record, ok := s.peers[relayID]
138
+ if !ok {
139
+ return ""
140
+ }
141
+ return record.seedURL
142
+}
143
+
144
+func (s *Cache) KnownDescriptors() []types.RelayDescriptor {
145
+ s.mu.RLock()
146
+ defer s.mu.RUnlock()
147
+
148
+ out := make([]types.RelayDescriptor, 0, len(s.peers))
149
+ for _, record := range s.peers {
150
+ if strings.TrimSpace(record.state.Descriptor.APIHTTPSAddr) == "" {
151
+ continue
152
+ }
153
+ out = append(out, record.state.Descriptor)
154
+ }
155
+ sort.Slice(out, func(i, j int) bool {
156
+ return out[i].APIHTTPSAddr < out[j].APIHTTPSAddr
157
+ })
158
+ return out
159
+}
160
+
161
+func (s *Cache) AdvertisedDescriptors() []types.RelayDescriptor {
162
+ s.mu.RLock()
163
+ defer s.mu.RUnlock()
164
+
165
+ out := make([]types.RelayDescriptor, 0, len(s.peers))
166
+ for _, record := range s.peers {
167
+ if record.state.State != types.PeerStateAdvertised {
168
+ continue
169
+ }
170
+ out = append(out, record.state.Descriptor)
171
+ }
172
+ sort.Slice(out, func(i, j int) bool {
173
+ return out[i].APIHTTPSAddr < out[j].APIHTTPSAddr
174
+ })
175
+ return out
176
+}
177
+
178
+func (s *Cache) HasPinnedIdentity(relayID string) bool {
179
+ if strings.TrimSpace(relayID) == "" {
180
+ return false
181
+ }
182
+
183
+ s.mu.RLock()
184
+ defer s.mu.RUnlock()
185
+
186
+ record, ok := s.peers[relayID]
187
+ return ok && strings.TrimSpace(record.pinnedSignerPublicKey) != ""
188
+}
189
+
190
+func (s *Cache) PinIdentity(relayID, seedURL string, desc types.RelayDescriptor) error {
191
+ relayID = strings.TrimSpace(relayID)
192
+ if relayID == "" {
193
+ return errors.New("relay id is required")
194
+ }
195
+ normalizedSeedURL, err := utils.NormalizeRelayURL(seedURL)
196
+ if err != nil {
197
+ return err
198
+ }
199
+ if strings.TrimSpace(desc.APIHTTPSAddr) == "" {
200
+ return errors.New("descriptor api_https_addr is required")
201
+ }
202
+ if desc.APIHTTPSAddr != normalizedSeedURL {
203
+ return errors.New("descriptor api_https_addr does not match seed url")
204
+ }
205
+ if strings.TrimSpace(desc.SignerPublicKey) == "" {
206
+ return errors.New("descriptor signer_public_key is required")
207
+ }
208
+
209
+ now := time.Now().UTC()
210
+
211
+ s.mu.Lock()
212
+ defer s.mu.Unlock()
213
+
214
+ record, ok := s.peers[relayID]
215
+ if !ok {
216
+ record = peerRecord{
217
+ state: types.PeerState{
218
+ FirstSeenAt: now,
219
+ },
220
+ }
221
+ }
222
+ if record.seedURL != "" && record.seedURL != normalizedSeedURL {
223
+ return errors.New("seed url does not match cached relay url")
224
+ }
225
+ if record.pinnedSignerPublicKey != "" && record.pinnedSignerPublicKey != desc.SignerPublicKey {
226
+ return errors.New("descriptor signer_public_key does not match pinned signer")
227
+ }
228
+
229
+ record.seedURL = normalizedSeedURL
230
+ record.pinnedSignerPublicKey = desc.SignerPublicKey
231
+ s.peers[relayID] = record
232
+ return nil
233
+}
234
+
235
+func (s *Cache) RecordVerified(desc types.RelayDescriptor, advertise bool) (bool, bool, error) {
236
+ if strings.TrimSpace(desc.RelayID) == "" {
237
+ return false, false, errors.New("relay id is required")
238
+ }
239
+
240
+ now := time.Now().UTC()
241
+
242
+ s.mu.Lock()
243
+ defer s.mu.Unlock()
244
+
245
+ record, ok := s.peers[desc.RelayID]
246
+ added := !ok
247
+ if !ok {
248
+ record = peerRecord{
249
+ state: types.PeerState{
250
+ FirstSeenAt: now,
251
+ },
252
+ }
253
+ }
254
+
255
+ if strings.TrimSpace(record.seedURL) == "" {
256
+ record.seedURL = strings.TrimSpace(desc.APIHTTPSAddr)
257
+ } else if strings.TrimSpace(desc.APIHTTPSAddr) != "" && record.seedURL != strings.TrimSpace(desc.APIHTTPSAddr) {
258
+ return false, false, errors.New("descriptor api_https_addr does not match cached seed url")
259
+ }
260
+ if record.pinnedSignerPublicKey != "" && record.pinnedSignerPublicKey != desc.SignerPublicKey {
261
+ return false, false, errors.New("descriptor signer_public_key does not match pinned signer")
262
+ }
263
+
264
+ previousState := record.state.State
265
+ previousDescriptor := record.state.Descriptor
266
+ switch {
267
+ case advertise:
268
+ record.state.State = types.PeerStateAdvertised
269
+ case record.state.State == types.PeerStateAdvertised:
270
+ record.state.State = types.PeerStateAdvertised
271
+ default:
272
+ record.state.State = types.PeerStateVerified
273
+ }
274
+
275
+ record.state.Descriptor = desc
276
+ record.state.LastSeenAt = now
277
+ record.state.ConsecutiveFailures = 0
278
+ s.peers[desc.RelayID] = record
279
+
280
+ changed := added ||
281
+ previousState != record.state.State ||
282
+ !reflect.DeepEqual(previousDescriptor, record.state.Descriptor)
283
+ return added, changed, nil
284
+}
285
+
286
+func (s *Cache) RecordFailure(relayID string) {
287
+ if strings.TrimSpace(relayID) == "" {
288
+ return
289
+ }
290
+
291
+ s.mu.Lock()
292
+ defer s.mu.Unlock()
293
+
294
+ record, ok := s.peers[relayID]
295
+ if !ok {
296
+ return
297
+ }
298
+ record.state.ConsecutiveFailures++
299
+ s.peers[relayID] = record
300
+}
301
+
302
+func (s *Cache) Expire(relayID string) bool {
303
+ if strings.TrimSpace(relayID) == "" {
304
+ return false
305
+ }
306
+
307
+ s.mu.Lock()
308
+ defer s.mu.Unlock()
309
+
310
+ record, ok := s.peers[relayID]
311
+ if !ok {
312
+ return false
313
+ }
314
+ if record.state.State == types.PeerStateExpired {
315
+ return false
316
+ }
317
+ record.state.State = types.PeerStateExpired
318
+ s.peers[relayID] = record
319
+ return true
320
+}
portal/discovery/store_test.go
new
+90
@@ -0,0 +1,90 @@
1
+package discovery
2
+
3
+import (
4
+ "strings"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/gosuda/portal/v2/types"
9
+)
10
+
11
+func signedRelayDescriptor(t *testing.T, privateKey, relayURL string) types.RelayDescriptor {
12
+ t.Helper()
13
+
14
+ identity, err := ResolveIdentity(privateKey)
15
+ if err != nil {
16
+ t.Fatalf("ResolveIdentity() error = %v", err)
17
+ }
18
+
19
+ now := time.Now().UTC()
20
+ desc, err := SignedDescriptor(types.RelayDescriptor{
21
+ RelayID: relayURL,
22
+ OwnerAddress: identity.Address,
23
+ SignerPublicKey: identity.PublicKey,
24
+ Sequence: uint64(now.UnixMilli()),
25
+ Version: 1,
26
+ IssuedAt: now,
27
+ ExpiresAt: now.Add(time.Hour),
28
+ APIHTTPSAddr: relayURL,
29
+ SupportsTCP: true,
30
+ StatusState: "healthy",
31
+ }, identity.PrivateKey)
32
+ if err != nil {
33
+ t.Fatalf("SignedDescriptor() error = %v", err)
34
+ }
35
+ return desc
36
+}
37
+
38
+func TestCacheRecordVerifiedReportsDescriptorChanges(t *testing.T) {
39
+ t.Parallel()
40
+
41
+ cache := NewCache()
42
+ if _, err := cache.UpsertSeedURLs([]string{"https://relay-a.example.com"}); err != nil {
43
+ t.Fatalf("UpsertSeedURLs() error = %v", err)
44
+ }
45
+
46
+ desc := signedRelayDescriptor(t, strings.Repeat("11", 32), "https://relay-a.example.com")
47
+ if err := cache.PinIdentity(desc.RelayID, desc.APIHTTPSAddr, desc); err != nil {
48
+ t.Fatalf("PinIdentity() error = %v", err)
49
+ }
50
+
51
+ added, changed, err := cache.RecordVerified(desc, true)
52
+ if err != nil {
53
+ t.Fatalf("RecordVerified() error = %v", err)
54
+ }
55
+ if added || !changed {
56
+ t.Fatalf("RecordVerified() = added:%v changed:%v, want false true", added, changed)
57
+ }
58
+
59
+ updated := desc
60
+ updated.StatusState = "degraded"
61
+ updated.DescriptorSignature, err = SignDescriptor(updated, strings.Repeat("11", 32))
62
+ if err != nil {
63
+ t.Fatalf("SignDescriptor() error = %v", err)
64
+ }
65
+
66
+ added, changed, err = cache.RecordVerified(updated, true)
67
+ if err != nil {
68
+ t.Fatalf("RecordVerified() second error = %v", err)
69
+ }
70
+ if added || !changed {
71
+ t.Fatalf("RecordVerified() second = added:%v changed:%v, want false true", added, changed)
72
+ }
73
+}
74
+
75
+func TestCacheKnownDescriptorsIncludeExpiredForRehydration(t *testing.T) {
76
+ t.Parallel()
77
+
78
+ cache := NewCache()
79
+ if _, err := cache.UpsertSeedURLs([]string{"https://relay-a.example.com"}); err != nil {
80
+ t.Fatalf("UpsertSeedURLs() error = %v", err)
81
+ }
82
+ if !cache.Expire("https://relay-a.example.com") {
83
+ t.Fatal("Expire() = false, want true")
84
+ }
85
+
86
+ known := cache.KnownDescriptors()
87
+ if len(known) != 1 || known[0].RelayID != "https://relay-a.example.com" {
88
+ t.Fatalf("KnownDescriptors() = %+v, want expired relay retained for rehydration", known)
89
+ }
90
+}
portal/server.go
+343
-85
@@ -8,6 +8,7 @@ import (
8
"io"
9
"net"
10
"net/http"
11
+ "sort"
12
"strings"
13
"sync"
14
"time"
@@ -22,25 +23,33 @@ import (
23
"github.com/gosuda/portal/v2/portal/keyless"
24
"github.com/gosuda/portal/v2/portal/policy"
25
"github.com/gosuda/portal/v2/portal/transport"
26
+ "github.com/gosuda/portal/v2/portal/wireguard"
27
"github.com/gosuda/portal/v2/types"
28
"github.com/gosuda/portal/v2/utils"
29
)
30
31
const (
30
- defaultLeaseTTL = 30 * time.Second
31
- defaultClaimTimeout = 10 * time.Second
32
- defaultDiscoveryInterval = 30 * time.Second
33
- defaultIdleKeepalive = 15 * time.Second
34
- defaultReadyQueueLimit = 8
35
- defaultClientHelloWait = 2 * time.Second
36
- defaultControlBodyLimit = 4 << 20
37
- defaultUDPPortBase = 50000
32
+ defaultLeaseTTL = 30 * time.Second
33
+ defaultClaimTimeout = 10 * time.Second
34
+ defaultDiscoveryInterval = 30 * time.Second
35
+ defaultIdleKeepalive = 15 * time.Second
36
+ defaultReadyQueueLimit = 8
37
+ defaultClientHelloWait = 2 * time.Second
38
+ defaultControlBodyLimit = 4 << 20
39
+ defaultUDPPortBase = 50000
40
+ defaultWGRecoveryFailures = 3
41
)
42
43
type ServerConfig struct {
44
PortalURL string
45
OwnerPrivateKey string
46
Bootstraps []string
47
+ WireGuardPrivateKey string
48
+ DiscoveryPort int
49
+ WireGuardPublicKey string
50
+ WireGuardEndpoint string
51
+ OverlayIPv4 string
52
+ OverlayCIDRs []string
53
ACME acme.Config
54
APIPort int
55
SNIPort int
@@ -59,23 +68,25 @@ type ServerConfig struct {
68
}
69
70
type Server struct {
62
- sniListener net.Listener
63
- apiListener net.Listener
64
- apiServer *http.Server
65
- apiTLSClose io.Closer
66
- acmeManager *acme.Manager
67
- quicTunnel *quic.Listener
68
- cancel context.CancelFunc
69
- group *errgroup.Group
70
- registry *leaseRegistry
71
- ports *transport.PortAllocator
72
- ownerIdentity discovery.Identity
73
- cfg ServerConfig
74
- rootHost string
75
- trustedProxyCIDRs []*net.IPNet
76
- discoveryMu sync.RWMutex
77
- discoveryBootstraps []string
78
- shutdownOnce sync.Once
71
+ sniListener net.Listener
72
+ apiListener net.Listener
73
+ apiServer *http.Server
74
+ wgPeerListener net.Listener
75
+ wgPeerServer *http.Server
76
+ apiTLSClose io.Closer
77
+ acmeManager *acme.Manager
78
+ quicTunnel *quic.Listener
79
+ wgRuntime *wireguard.Runtime
80
+ cancel context.CancelFunc
81
+ group *errgroup.Group
82
+ registry *leaseRegistry
83
+ ports *transport.PortAllocator
84
+ ownerIdentity discovery.Identity
85
+ cfg ServerConfig
86
+ rootHost string
87
+ trustedProxyCIDRs []*net.IPNet
88
+ discoveryCache *discovery.Cache
89
+ shutdownOnce sync.Once
90
}
91
92
func NewServer(cfg ServerConfig) (*Server, error) {
@@ -103,6 +114,50 @@ func NewServer(cfg ServerConfig) (*Server, error) {
114
return nil, fmt.Errorf("normalize bootstraps: %w", err)
115
}
116
cfg.Bootstraps = bootstraps
117
+ wireGuardConfigured := strings.TrimSpace(cfg.WireGuardPrivateKey) != "" ||
118
+ strings.TrimSpace(cfg.WireGuardPublicKey) != "" ||
119
+ strings.TrimSpace(cfg.WireGuardEndpoint) != "" ||
120
+ strings.TrimSpace(cfg.OverlayIPv4) != "" ||
121
+ len(cfg.OverlayCIDRs) > 0
122
+ if wireGuardConfigured {
123
+ if strings.TrimSpace(cfg.WireGuardPrivateKey) == "" {
124
+ return nil, errors.New("wireguard private key is required when relay overlay is enabled")
125
+ }
126
+ cfg.WireGuardPrivateKey, err = utils.NormalizeWireGuardPrivateKey(cfg.WireGuardPrivateKey)
127
+ if err != nil {
128
+ return nil, fmt.Errorf("normalize wireguard private key: %w", err)
129
+ }
130
+ derivedPublicKey, err := utils.WireGuardPublicKeyFromPrivate(cfg.WireGuardPrivateKey)
131
+ if err != nil {
132
+ return nil, fmt.Errorf("derive wireguard public key: %w", err)
133
+ }
134
+ if configuredPublicKey := strings.TrimSpace(cfg.WireGuardPublicKey); configuredPublicKey != "" && configuredPublicKey != derivedPublicKey {
135
+ return nil, errors.New("wireguard public key does not match private key")
136
+ }
137
+ cfg.WireGuardPublicKey = derivedPublicKey
138
+ cfg.DiscoveryPort = utils.IntOrDefault(cfg.DiscoveryPort, wireguard.DefaultListenPort)
139
+ if len(cfg.OverlayCIDRs) > 0 {
140
+ cfg.OverlayCIDRs, err = discovery.NormalizeOverlayCIDRs(cfg.OverlayCIDRs)
141
+ if err != nil {
142
+ return nil, fmt.Errorf("normalize overlay cidrs: %w", err)
143
+ }
144
+ }
145
+ if strings.TrimSpace(cfg.WireGuardEndpoint) == "" {
146
+ cfg.WireGuardEndpoint = net.JoinHostPort(rootHost, fmt.Sprintf("%d", cfg.DiscoveryPort))
147
+ }
148
+ if strings.TrimSpace(cfg.OverlayIPv4) == "" {
149
+ cfg.OverlayIPv4, err = utils.DeriveWireGuardOverlayIPv4(cfg.WireGuardPublicKey)
150
+ if err != nil {
151
+ return nil, fmt.Errorf("derive overlay ipv4: %w", err)
152
+ }
153
+ }
154
+ if err := discovery.ValidateWireGuardEndpoint(cfg.WireGuardEndpoint); err != nil {
155
+ return nil, err
156
+ }
157
+ if err := discovery.ValidateOverlayIPv4(cfg.OverlayIPv4); err != nil {
158
+ return nil, err
159
+ }
160
+ }
161
162
portMin, portMax := 0, 0
163
if cfg.UDPPortCount > 0 {
@@ -146,15 +201,10 @@ func NewServer(cfg ServerConfig) (*Server, error) {
201
}
202
203
if cfg.DiscoveryEnabled {
149
- bootstraps, err := utils.MergeRelayURLs(nil, []string{cfg.PortalURL}, cfg.Bootstraps)
150
- if err != nil {
204
+ s.discoveryCache = discovery.NewCache()
205
+ if _, err := s.discoveryCache.UpsertSeedURLs(cfg.Bootstraps); err != nil {
206
return nil, err
207
}
153
- bootstraps, err = utils.ExcludeLocalRelayURLs(bootstraps...)
154
- if err != nil {
155
- return nil, err
156
- }
157
- s.discoveryBootstraps = bootstraps
208
}
209
210
return s, nil
@@ -204,7 +254,21 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
254
s.cancel = cancel
255
s.group = group
256
257
+ if s.wireGuardPeerPlaneEnabled() {
258
+ if err := s.startWireGuardPeerPlane(); err != nil {
259
+ acmeManager.Stop()
260
+ _ = apiServer.Close()
261
+ _ = apiCloser.Close()
262
+ _ = sniListener.Close()
263
+ cancel()
264
+ return fmt.Errorf("start wireguard peer plane: %w", err)
265
+ }
266
+ }
267
+
268
group.Go(s.runAPIServer)
269
+ if s.wgPeerServer != nil {
270
+ group.Go(s.runWireGuardPeerAPIServer)
271
+ }
272
group.Go(func() error { return s.runSNIListener(groupCtx) })
273
group.Go(func() error { return s.registry.RunJanitor(groupCtx, 5*time.Second) })
274
if s.DiscoveryEnabled() {
@@ -253,6 +317,14 @@ func (s *Server) Shutdown(ctx context.Context) error {
317
shutdownErr = err
318
}
319
}
320
+ if s.wgPeerServer != nil {
321
+ if err := s.wgPeerServer.Shutdown(ctx); err != nil && shutdownErr == nil && !errors.Is(err, http.ErrServerClosed) {
322
+ shutdownErr = err
323
+ }
324
+ }
325
+ if s.wgRuntime != nil {
326
+ _ = s.wgRuntime.Close()
327
+ }
328
if s.apiTLSClose != nil {
329
_ = s.apiTLSClose.Close()
330
}
@@ -302,6 +374,16 @@ func (s *Server) PortalURL() string {
374
return s.cfg.PortalURL
375
}
376
377
+func (s *Server) wireGuardPeerPlaneEnabled() bool {
378
+ if s == nil {
379
+ return false
380
+ }
381
+ return strings.TrimSpace(s.cfg.WireGuardPrivateKey) != "" &&
382
+ strings.TrimSpace(s.cfg.WireGuardPublicKey) != "" &&
383
+ strings.TrimSpace(s.cfg.WireGuardEndpoint) != "" &&
384
+ strings.TrimSpace(s.cfg.OverlayIPv4) != ""
385
+}
386
+
387
func (s *Server) OwnerAddress() string {
388
if s == nil {
389
return ""
@@ -343,6 +425,68 @@ func (s *Server) LeaseSnapshotByHostname(hostname string) (types.Lease, bool) {
425
return s.registry.Snapshot(record), true
426
}
427
428
+func (s *Server) startWireGuardPeerPlane() error {
429
+ runtime, err := wireguard.NewRuntime(wireguard.RuntimeConfig{
430
+ PrivateKey: s.cfg.WireGuardPrivateKey,
431
+ Endpoint: s.cfg.WireGuardEndpoint,
432
+ OverlayIPv4: s.cfg.OverlayIPv4,
433
+ })
434
+ if err != nil {
435
+ return err
436
+ }
437
+
438
+ listener, err := runtime.ListenTCP(wireguard.DefaultPeerAPIHTTPPort)
439
+ if err != nil {
440
+ _ = runtime.Close()
441
+ return fmt.Errorf("listen peer api: %w", err)
442
+ }
443
+
444
+ server := &http.Server{
445
+ Handler: s.peerAPIHandler(),
446
+ ReadHeaderTimeout: 10 * time.Second,
447
+ }
448
+
449
+ s.wgRuntime = runtime
450
+ s.wgPeerListener = listener
451
+ s.wgPeerServer = server
452
+
453
+ if err := s.syncWireGuardPeers(); err != nil {
454
+ _ = server.Close()
455
+ _ = runtime.Close()
456
+ s.wgRuntime = nil
457
+ s.wgPeerListener = nil
458
+ s.wgPeerServer = nil
459
+ return fmt.Errorf("seed wireguard peers: %w", err)
460
+ }
461
+ return nil
462
+}
463
+
464
+func (s *Server) runWireGuardPeerAPIServer() error {
465
+ if s == nil || s.wgPeerServer == nil || s.wgPeerListener == nil {
466
+ return nil
467
+ }
468
+
469
+ err := s.wgPeerServer.Serve(s.wgPeerListener)
470
+ if errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
471
+ return nil
472
+ }
473
+ return err
474
+}
475
+
476
+func (s *Server) peerAPIHandler() http.Handler {
477
+ mux := http.NewServeMux()
478
+ mux.HandleFunc(types.PathRoot, s.handleRoot)
479
+ mux.HandleFunc(types.PathHealthz, s.handleHealthz)
480
+ mux.HandleFunc(types.PathDiscovery, func(w http.ResponseWriter, r *http.Request) {
481
+ if !s.DiscoveryEnabled() {
482
+ http.NotFound(w, r)
483
+ return
484
+ }
485
+ discovery.ServeHTTP(w, r, s.discover)
486
+ })
487
+ return mux
488
+}
489
+
490
func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig, *acme.Manager, error) {
491
acmeCfg := s.cfg.ACME
492
if baseDomain := utils.NormalizeHostname(acmeCfg.BaseDomain); baseDomain != "" && baseDomain != s.rootHost {
@@ -578,48 +722,66 @@ func (s *Server) watchContext(ctx context.Context) error {
722
return s.Shutdown(shutdownCtx)
723
}
724
581
-func (s *Server) discoveryBootstrapsSnapshot() []string {
582
- if s == nil || !s.DiscoveryEnabled() {
725
+func (s *Server) desiredWireGuardPeers() []types.DesiredPeer {
726
+ if s.wgRuntime == nil {
727
return nil
728
}
729
586
- s.discoveryMu.RLock()
587
- defer s.discoveryMu.RUnlock()
588
- return append([]string(nil), s.discoveryBootstraps...)
730
+ snapshot := s.discoveryCache.Snapshot()
731
+ peers := make([]types.DesiredPeer, 0, len(snapshot))
732
+ for _, state := range snapshot {
733
+ if state.State != types.PeerStateVerified && state.State != types.PeerStateAdvertised {
734
+ continue
735
+ }
736
+ desc := state.Descriptor
737
+ if desc.RelayID == s.cfg.PortalURL || !desc.SupportsOverlayPeer {
738
+ continue
739
+ }
740
+ if strings.TrimSpace(desc.WireGuardPublicKey) == "" || strings.TrimSpace(desc.WireGuardEndpoint) == "" || strings.TrimSpace(desc.OverlayIPv4) == "" {
741
+ continue
742
+ }
743
+
744
+ allowedIPs := []string{desc.OverlayIPv4 + "/32"}
745
+ allowedIPs = append(allowedIPs, desc.OverlayCIDRs...)
746
+ peers = append(peers, types.DesiredPeer{
747
+ RelayID: desc.RelayID,
748
+ WireGuardPublicKey: desc.WireGuardPublicKey,
749
+ WireGuardEndpoint: desc.WireGuardEndpoint,
750
+ AllowedIPs: allowedIPs,
751
+ })
752
+ }
753
+ sort.Slice(peers, func(i, j int) bool {
754
+ return peers[i].RelayID < peers[j].RelayID
755
+ })
756
+ return peers
757
}
758
591
-func (s *Server) mergeDiscoveryBootstraps(inputs []string) ([]string, error) {
592
- if s == nil || !s.DiscoveryEnabled() || len(inputs) == 0 {
593
- return nil, nil
759
+func (s *Server) syncWireGuardPeers() error {
760
+ if s.wgRuntime == nil {
761
+ return nil
762
}
763
+ return s.wgRuntime.ApplyPeers(s.desiredWireGuardPeers())
764
+}
765
596
- s.discoveryMu.Lock()
597
- defer s.discoveryMu.Unlock()
598
-
599
- next, err := utils.MergeRelayURLs(s.discoveryBootstraps, []string{s.cfg.PortalURL}, inputs)
600
- if err != nil {
601
- return nil, err
602
- }
603
- next, err = utils.ExcludeLocalRelayURLs(next...)
604
- if err != nil {
605
- return nil, err
766
+func (s *Server) discoverRelay(ctx context.Context, peer types.RelayDescriptor) (types.DiscoverResponse, error) {
767
+ if peer.SupportsOverlayPeer && s.discoveryCache.HasPinnedIdentity(peer.RelayID) {
768
+ state, ok := s.discoveryCache.Lookup(peer.RelayID)
769
+ if ok && state.State != types.PeerStateExpired && s.wgRuntime != nil {
770
+ if strings.TrimSpace(peer.OverlayIPv4) == "" {
771
+ return types.DiscoverResponse{}, errors.New("relay peer is missing overlay ipv4")
772
+ }
773
+ return s.wgRuntime.Discover(ctx, peer.OverlayIPv4, wireguard.DefaultPeerAPIHTTPPort, types.DiscoverRequest{})
774
+ }
775
}
776
608
- existing := make(map[string]struct{}, len(s.discoveryBootstraps))
609
- for _, bootstrap := range s.discoveryBootstraps {
610
- existing[bootstrap] = struct{}{}
777
+ seedURL := strings.TrimSpace(s.discoveryCache.SeedURL(peer.RelayID))
778
+ if seedURL == "" {
779
+ seedURL = strings.TrimSpace(peer.APIHTTPSAddr)
780
}
612
-
613
- added := make([]string, 0, len(next))
614
- for _, bootstrap := range next {
615
- if _, ok := existing[bootstrap]; ok {
616
- continue
617
- }
618
- added = append(added, bootstrap)
781
+ if seedURL == "" {
782
+ return types.DiscoverResponse{}, errors.New("relay peer is missing seed url")
783
}
620
-
621
- s.discoveryBootstraps = next
622
- return added, nil
784
+ return discovery.Discover(ctx, seedURL, types.DiscoverRequest{}, nil)
785
}
786
787
func (s *Server) runDiscoveryLoop(ctx context.Context) error {
@@ -627,32 +789,128 @@ func (s *Server) runDiscoveryLoop(ctx context.Context) error {
789
defer ticker.Stop()
790
791
for {
630
- peers := s.discoveryBootstrapsSnapshot()
792
+ peers := s.discoveryCache.KnownDescriptors()
793
if len(peers) > 0 {
632
- bootstraps, err := discovery.DiscoverBootstraps(ctx, peers, types.DiscoverRequest{}, nil)
633
- switch {
634
- case err == nil:
635
- added, err := s.mergeDiscoveryBootstraps(bootstraps)
636
- if err != nil {
794
+ for _, peer := range peers {
795
+ resp, err := s.discoverRelay(ctx, peer)
796
+ switch {
797
+ case err == nil:
798
+ selfDescriptor, peerDescriptors, resolveErr := discovery.ResolvePeerResponse(resp, time.Now().UTC())
799
+ if strings.TrimSpace(selfDescriptor.RelayID) == "" {
800
+ s.discoveryCache.RecordFailure(peer.RelayID)
801
+ log.Warn().
802
+ Err(resolveErr).
803
+ Str("peer", peer.APIHTTPSAddr).
804
+ Msg("discovery response missing valid self descriptor")
805
+ continue
806
+ }
807
+ if resolveErr != nil {
808
+ log.Warn().
809
+ Err(resolveErr).
810
+ Str("peer", peer.APIHTTPSAddr).
811
+ Msg("discovery response contained invalid peer descriptors")
812
+ }
813
+ if seedURL := strings.TrimSpace(s.discoveryCache.SeedURL(peer.RelayID)); seedURL != "" {
814
+ if err := s.discoveryCache.PinIdentity(peer.RelayID, seedURL, selfDescriptor); err != nil {
815
+ s.discoveryCache.RecordFailure(peer.RelayID)
816
+ log.Warn().
817
+ Err(err).
818
+ Str("peer", peer.APIHTTPSAddr).
819
+ Msg("discovery peer identity pin failed")
820
+ continue
821
+ }
822
+ }
823
+
824
+ peerSetChanged := false
825
+ added, changed, err := s.discoveryCache.RecordVerified(selfDescriptor, true)
826
+ if err != nil {
827
+ s.discoveryCache.RecordFailure(selfDescriptor.RelayID)
828
+ log.Warn().
829
+ Err(err).
830
+ Str("peer", peer.APIHTTPSAddr).
831
+ Msg("record self discovery peer failed")
832
+ continue
833
+ }
834
+ peerSetChanged = peerSetChanged || changed
835
+ addedHints := make([]string, 0, len(peerDescriptors))
836
+ for _, peerDescriptor := range peerDescriptors {
837
+ hintAdded, hintChanged, err := s.discoveryCache.RecordVerified(peerDescriptor, false)
838
+ if err != nil {
839
+ log.Warn().
840
+ Err(err).
841
+ Str("peer", peerDescriptor.RelayID).
842
+ Msg("record hinted discovery peer failed")
843
+ continue
844
+ }
845
+ peerSetChanged = peerSetChanged || hintChanged
846
+ if hintAdded || hintChanged {
847
+ addedHints = append(addedHints, peerDescriptor.APIHTTPSAddr)
848
+ }
849
+ }
850
+ if peerSetChanged {
851
+ if err := s.syncWireGuardPeers(); err != nil {
852
+ log.Warn().
853
+ Err(err).
854
+ Str("peer", peer.APIHTTPSAddr).
855
+ Msg("sync wireguard peers failed")
856
+ }
857
+ }
858
+
859
+ if added || changed || len(addedHints) > 0 {
860
+ log.Info().
861
+ Str("peer", peer.APIHTTPSAddr).
862
+ Bool("discoverable", selfDescriptor.SupportsOverlayPeer).
863
+ Int("hint_count", len(addedHints)).
864
+ Int("known_count", len(s.discoveryCache.KnownDescriptors())).
865
+ Int("advertised_count", len(s.discoveryCache.AdvertisedDescriptors())).
866
+ Strs("added_hints", addedHints).
867
+ Msg("discovery peer state updated")
868
+ }
869
+ case ctx.Err() != nil:
870
+ return nil
871
+ default:
872
+ s.discoveryCache.RecordFailure(peer.RelayID)
873
+ if state, ok := s.discoveryCache.Lookup(peer.RelayID); ok &&
874
+ state.State != types.PeerStateExpired &&
875
+ peer.SupportsOverlayPeer &&
876
+ s.discoveryCache.HasPinnedIdentity(peer.RelayID) &&
877
+ state.ConsecutiveFailures >= defaultWGRecoveryFailures {
878
+ if removed := s.discoveryCache.Expire(peer.RelayID); removed {
879
+ if err := s.syncWireGuardPeers(); err != nil {
880
+ log.Warn().
881
+ Err(err).
882
+ Str("peer", peer.APIHTTPSAddr).
883
+ Msg("sync wireguard peers failed")
884
+ }
885
+ log.Warn().
886
+ Int("consecutive_failures", state.ConsecutiveFailures).
887
+ Str("peer", peer.APIHTTPSAddr).
888
+ Msg("wireguard discovery failed repeatedly, forcing seed re-hydration")
889
+ }
890
+ }
891
+ var apiErr *types.APIRequestError
892
+ if errors.As(err, &apiErr) &&
893
+ (apiErr.StatusCode == http.StatusForbidden ||
894
+ apiErr.StatusCode == http.StatusNotFound ||
895
+ apiErr.StatusCode == http.StatusGone) {
896
+ if removed := s.discoveryCache.Expire(peer.RelayID); removed {
897
+ if err := s.syncWireGuardPeers(); err != nil {
898
+ log.Warn().
899
+ Err(err).
900
+ Str("peer", peer.APIHTTPSAddr).
901
+ Msg("sync wireguard peers failed")
902
+ }
903
+ log.Info().
904
+ Str("peer", peer.APIHTTPSAddr).
905
+ Msg("discovery peer removed from advertised set")
906
+ }
907
+ }
908
+
909
log.Warn().
910
Err(err).
639
- Int("bootstrap_count", len(peers)).
640
- Msg("merge discovered bootstraps failed")
641
- } else if len(added) > 0 {
642
- log.Info().
643
- Int("peer_count", len(peers)).
644
- Int("added_count", len(added)).
645
- Int("total_bootstrap_count", len(s.discoveryBootstrapsSnapshot())).
646
- Strs("added_bootstraps", added).
647
- Msg("discovery bootstraps updated")
911
+ Str("peer", peer.APIHTTPSAddr).
912
+ Msg("discover peer failed")
913
}
649
- case ctx.Err() != nil:
650
- return nil
651
- default:
652
- log.Warn().
653
- Err(err).
654
- Int("bootstrap_count", len(peers)).
655
- Msg("discover bootstraps failed")
914
}
915
}
916
portal/server_test.go
+214
-16
@@ -4,10 +4,12 @@ import (
4
"context"
5
"crypto/tls"
6
"encoding/json"
7
+ "net"
8
"net/http"
9
"reflect"
10
"strings"
11
"testing"
12
+ "time"
13
14
"github.com/gosuda/portal/v2/portal/acme"
15
"github.com/gosuda/portal/v2/portal/discovery"
@@ -15,6 +17,43 @@ import (
17
"github.com/gosuda/portal/v2/utils"
18
)
19
20
+func mustSignedRelayDescriptor(t *testing.T, ownerPrivateKey, relayURL string) types.RelayDescriptor {
21
+ t.Helper()
22
+
23
+ identity, err := discovery.ResolveIdentity(ownerPrivateKey)
24
+ if err != nil {
25
+ t.Fatalf("ResolveIdentity() error = %v", err)
26
+ }
27
+
28
+ now := time.Now().UTC()
29
+ desc, err := discovery.SignedDescriptor(types.RelayDescriptor{
30
+ RelayID: relayURL,
31
+ OwnerAddress: identity.Address,
32
+ SignerPublicKey: identity.PublicKey,
33
+ Sequence: uint64(now.UnixMilli()),
34
+ Version: 1,
35
+ IssuedAt: now,
36
+ ExpiresAt: now.Add(time.Hour),
37
+ APIHTTPSAddr: relayURL,
38
+ SupportsTCP: true,
39
+ StatusState: "healthy",
40
+ }, identity.PrivateKey)
41
+ if err != nil {
42
+ t.Fatalf("SignedDescriptor() error = %v", err)
43
+ }
44
+ return desc
45
+}
46
+
47
+func mustRelayAPIURLs(t *testing.T, descriptors []types.RelayDescriptor) []string {
48
+ t.Helper()
49
+
50
+ urls, err := discovery.RelayAPIURLs(descriptors)
51
+ if err != nil {
52
+ t.Fatalf("RelayAPIURLs() error = %v", err)
53
+ }
54
+ return urls
55
+}
56
+
57
func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
58
t.Parallel()
59
@@ -101,6 +140,61 @@ func TestServerStartRejectsMismatchedACMEBaseDomain(t *testing.T) {
140
}
141
}
142
143
+func TestNewServerDerivesWireGuardConfigFromPrivateKey(t *testing.T) {
144
+ t.Parallel()
145
+
146
+ server, err := NewServer(ServerConfig{
147
+ PortalURL: "https://portal.example.com",
148
+ WireGuardPrivateKey: strings.Repeat("33", 32),
149
+ DiscoveryPort: 41011,
150
+ })
151
+ if err != nil {
152
+ t.Fatalf("NewServer() error = %v", err)
153
+ }
154
+
155
+ if server.cfg.WireGuardPrivateKey == "" {
156
+ t.Fatal("WireGuardPrivateKey = empty, want normalized key")
157
+ }
158
+ if server.cfg.WireGuardPublicKey == "" {
159
+ t.Fatal("WireGuardPublicKey = empty, want derived key")
160
+ }
161
+ if server.cfg.WireGuardEndpoint != net.JoinHostPort("portal.example.com", "41011") {
162
+ t.Fatalf("WireGuardEndpoint = %q, want %q", server.cfg.WireGuardEndpoint, net.JoinHostPort("portal.example.com", "41011"))
163
+ }
164
+ if server.cfg.OverlayIPv4 == "" {
165
+ t.Fatal("OverlayIPv4 = empty, want derived overlay address")
166
+ }
167
+ if err := discovery.ValidateWireGuardEndpoint(server.cfg.WireGuardEndpoint); err != nil {
168
+ t.Fatalf("ValidateWireGuardEndpoint() error = %v", err)
169
+ }
170
+ if err := discovery.ValidateOverlayIPv4(server.cfg.OverlayIPv4); err != nil {
171
+ t.Fatalf("ValidateOverlayIPv4() error = %v", err)
172
+ }
173
+
174
+ wantOverlay, err := utils.DeriveWireGuardOverlayIPv4(server.cfg.WireGuardPublicKey)
175
+ if err != nil {
176
+ t.Fatalf("DeriveWireGuardOverlayIPv4() error = %v", err)
177
+ }
178
+ if server.cfg.OverlayIPv4 != wantOverlay {
179
+ t.Fatalf("OverlayIPv4 = %q, want %q", server.cfg.OverlayIPv4, wantOverlay)
180
+ }
181
+}
182
+
183
+func TestNewServerIgnoresDiscoveryPortWithoutWireGuardKey(t *testing.T) {
184
+ t.Parallel()
185
+
186
+ server, err := NewServer(ServerConfig{
187
+ PortalURL: "https://portal.example.com",
188
+ DiscoveryPort: 51820,
189
+ })
190
+ if err != nil {
191
+ t.Fatalf("NewServer() error = %v", err)
192
+ }
193
+ if server.cfg.WireGuardEndpoint != "" {
194
+ t.Fatalf("WireGuardEndpoint = %q, want empty without wireguard key", server.cfg.WireGuardEndpoint)
195
+ }
196
+}
197
+
198
func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
199
t.Parallel()
200
@@ -203,13 +297,17 @@ func TestServerStartServesOptionalDiscoveryRoutes(t *testing.T) {
297
if err != nil {
298
t.Fatalf("NewServer() error = %v", err)
299
}
206
- if _, err := server.registerLease(types.RegisterRequest{
300
+ registerResp, err := server.registerLease(types.RegisterRequest{
301
Name: "demo",
302
ReverseToken: "tok_demo",
303
Bootstraps: []string{"https://relay-a.example.com", "https://bootstrap.example.com"},
210
- }, "203.0.113.10"); err != nil {
304
+ }, "203.0.113.10")
305
+ if err != nil {
306
t.Fatalf("registerLease() error = %v", err)
307
}
308
+ if !reflect.DeepEqual(registerResp.Bootstraps, []string{server.PortalURL()}) {
309
+ t.Fatalf("registerLease() bootstraps = %v, want [%q]", registerResp.Bootstraps, server.PortalURL())
310
+ }
311
312
ctx, cancel := context.WithCancel(context.Background())
313
defer cancel()
@@ -248,24 +346,53 @@ func TestServerStartServesOptionalDiscoveryRoutes(t *testing.T) {
346
if !envelope.OK {
347
t.Fatalf("discovery resolve envelope = %+v, want ok", envelope)
348
}
251
- if !envelope.Data.Found {
252
- t.Fatalf("resolve found = %v, want true", envelope.Data.Found)
349
+ if envelope.Data.ProtocolVersion != 1 {
350
+ t.Fatalf("resolve protocol_version = %d, want 1", envelope.Data.ProtocolVersion)
351
}
254
- if envelope.Data.OwnerAddress != ownerIdentity.Address {
255
- t.Fatalf("resolve owner address = %q, want relay owner address", envelope.Data.OwnerAddress)
352
+ if envelope.Data.GeneratedAt.IsZero() {
353
+ t.Fatal("resolve generated_at = zero, want timestamp")
354
}
257
- if envelope.Data.Hostname != "demo.localhost" {
258
- t.Fatalf("resolve hostname = %q, want %q", envelope.Data.Hostname, "demo.localhost")
355
+ if envelope.Data.Service == nil || !envelope.Data.Service.Found {
356
+ t.Fatalf("resolve service = %+v, want found=true", envelope.Data.Service)
357
}
260
- if !reflect.DeepEqual(envelope.Data.Bootstraps, []string{"https://bootstrap.example.com", "https://relay-a.example.com"}) {
261
- t.Fatalf("resolve bootstraps = %v, want [%q %q]", envelope.Data.Bootstraps, "https://bootstrap.example.com", "https://relay-a.example.com")
358
+ if envelope.Data.Service.OwnerAddress != ownerIdentity.Address {
359
+ t.Fatalf("resolve service owner address = %q, want relay owner address", envelope.Data.Service.OwnerAddress)
360
+ }
361
+ if envelope.Data.Service.Hostname != "demo.localhost" {
362
+ t.Fatalf("resolve service hostname = %q, want %q", envelope.Data.Service.Hostname, "demo.localhost")
363
+ }
364
+ if envelope.Data.Service.RelayID != envelope.Data.Self.RelayID {
365
+ t.Fatalf("resolve service relay_id = %q, want %q", envelope.Data.Service.RelayID, envelope.Data.Self.RelayID)
366
+ }
367
+
368
+ if _, err := discovery.ValidateDescriptor(envelope.Data.Self, time.Now().UTC()); err != nil {
369
+ t.Fatalf("ValidateDescriptor(self) error = %v", err)
370
+ }
371
+ relayURLs := make([]string, 0, 1+len(envelope.Data.Peers))
372
+ relayURLs = append(relayURLs, envelope.Data.Self.APIHTTPSAddr)
373
+ for _, peer := range envelope.Data.Peers {
374
+ if strings.TrimSpace(peer.APIHTTPSAddr) != "" {
375
+ relayURLs = append(relayURLs, peer.APIHTTPSAddr)
376
+ }
377
+ }
378
+ if !reflect.DeepEqual(relayURLs, []string{server.PortalURL()}) {
379
+ t.Fatalf("resolve relay urls = %v, want [%q]", relayURLs, server.PortalURL())
380
+ }
381
+ if envelope.Data.Self.OwnerAddress != ownerIdentity.Address {
382
+ t.Fatalf("self relay owner address = %q, want %q", envelope.Data.Self.OwnerAddress, ownerIdentity.Address)
383
+ }
384
+ if envelope.Data.Self.SignerPublicKey != ownerIdentity.PublicKey {
385
+ t.Fatalf("self relay signer public key = %q, want %q", envelope.Data.Self.SignerPublicKey, ownerIdentity.PublicKey)
386
+ }
387
+ if envelope.Data.Self.StatusState != "healthy" {
388
+ t.Fatalf("self relay status_state = %q, want %q", envelope.Data.Self.StatusState, "healthy")
389
}
390
if !server.DiscoveryEnabled() {
391
t.Fatal("DiscoveryEnabled() = false, want true")
392
}
393
}
394
268
-func TestServerMergeDiscoveryBootstrapsSkipsLocalRelayHosts(t *testing.T) {
395
+func TestServerUpsertDiscoverySeedURLsSkipsLocalRelayHosts(t *testing.T) {
396
t.Parallel()
397
398
server, err := NewServer(ServerConfig{
@@ -277,20 +404,91 @@ func TestServerMergeDiscoveryBootstrapsSkipsLocalRelayHosts(t *testing.T) {
404
t.Fatalf("NewServer() error = %v", err)
405
}
406
280
- added, err := server.mergeDiscoveryBootstraps([]string{
407
+ added, err := server.discoveryCache.UpsertSeedURLs([]string{
408
"https://localhost:4017",
409
"https://relay-a.example.com",
410
"https://127.0.0.1:4017",
411
})
412
if err != nil {
286
- t.Fatalf("mergeDiscoveryBootstraps() error = %v", err)
413
+ t.Fatalf("UpsertSeedURLs() error = %v", err)
414
}
415
416
if !reflect.DeepEqual(added, []string{"https://relay-a.example.com"}) {
290
- t.Fatalf("mergeDiscoveryBootstraps() added = %v, want [%q]", added, "https://relay-a.example.com")
417
+ t.Fatalf("UpsertSeedURLs() added = %v, want [%q]", added, "https://relay-a.example.com")
418
+ }
419
+ if !reflect.DeepEqual(mustRelayAPIURLs(t, server.discoveryCache.KnownDescriptors()), []string{"https://bootstrap.example.com", "https://relay-a.example.com"}) {
420
+ t.Fatalf("KnownDescriptors() = %v, want [%q %q]", mustRelayAPIURLs(t, server.discoveryCache.KnownDescriptors()), "https://bootstrap.example.com", "https://relay-a.example.com")
421
+ }
422
+ if len(server.discoveryCache.AdvertisedDescriptors()) != 0 {
423
+ t.Fatalf("AdvertisedDescriptors() = %v, want empty before direct confirmation", server.discoveryCache.AdvertisedDescriptors())
424
+ }
425
+}
426
+
427
+func TestServerRecordVerifiedDiscoveryPeerRequiresDirectConfirmation(t *testing.T) {
428
+ t.Parallel()
429
+
430
+ ownerPrivateKey := strings.Repeat("11", 32)
431
+ server, err := NewServer(ServerConfig{
432
+ PortalURL: "https://portal.example.com",
433
+ Bootstraps: []string{"https://bootstrap.example.com"},
434
+ OwnerPrivateKey: ownerPrivateKey,
435
+ DiscoveryEnabled: true,
436
+ })
437
+ if err != nil {
438
+ t.Fatalf("NewServer() error = %v", err)
439
+ }
440
+
441
+ bootstrapDesc := mustSignedRelayDescriptor(t, ownerPrivateKey, "https://bootstrap.example.com")
442
+ relayADesc := mustSignedRelayDescriptor(t, ownerPrivateKey, "https://relay-a.example.com")
443
+
444
+ added, changed, err := server.discoveryCache.RecordVerified(bootstrapDesc, true)
445
+ if err != nil {
446
+ t.Fatalf("RecordVerified() error = %v", err)
447
+ }
448
+ if added {
449
+ t.Fatal("RecordVerified() added = true, want false for seeded bootstrap")
450
+ }
451
+ if !changed {
452
+ t.Fatal("RecordVerified() changed = false, want true")
453
+ }
454
+
455
+ added, changed, err = server.discoveryCache.RecordVerified(relayADesc, false)
456
+ if err != nil {
457
+ t.Fatalf("RecordVerified() hinted error = %v", err)
458
+ }
459
+ if !added {
460
+ t.Fatal("RecordVerified() hinted add = false, want true")
461
+ }
462
+ if !changed {
463
+ t.Fatal("RecordVerified() hinted changed = false, want true")
464
+ }
465
+ if !reflect.DeepEqual(mustRelayAPIURLs(t, server.discoveryCache.KnownDescriptors()), []string{"https://bootstrap.example.com", "https://relay-a.example.com"}) {
466
+ t.Fatalf("KnownDescriptors() = %v, want [%q %q]", mustRelayAPIURLs(t, server.discoveryCache.KnownDescriptors()), "https://bootstrap.example.com", "https://relay-a.example.com")
467
+ }
468
+ if !reflect.DeepEqual(mustRelayAPIURLs(t, server.discoveryCache.AdvertisedDescriptors()), []string{"https://bootstrap.example.com"}) {
469
+ t.Fatalf("AdvertisedDescriptors() = %v, want [%q]", mustRelayAPIURLs(t, server.discoveryCache.AdvertisedDescriptors()), "https://bootstrap.example.com")
470
+ }
471
+
472
+ snapshot := server.discoveryCache.Snapshot()
473
+ if snapshot[bootstrapDesc.RelayID].State != types.PeerStateAdvertised {
474
+ t.Fatalf("bootstrap state = %q, want %q", snapshot[bootstrapDesc.RelayID].State, types.PeerStateAdvertised)
475
+ }
476
+ if snapshot[relayADesc.RelayID].State != types.PeerStateVerified {
477
+ t.Fatalf("relay-a state = %q, want %q", snapshot[relayADesc.RelayID].State, types.PeerStateVerified)
478
+ }
479
+
480
+ added, changed, err = server.discoveryCache.RecordVerified(relayADesc, true)
481
+ if err != nil {
482
+ t.Fatalf("RecordVerified() second error = %v", err)
483
+ }
484
+ if added {
485
+ t.Fatal("RecordVerified() second add = true, want false")
486
+ }
487
+ if !changed {
488
+ t.Fatal("RecordVerified() second changed = false, want true")
489
}
292
- if !reflect.DeepEqual(server.discoveryBootstrapsSnapshot(), []string{"https://bootstrap.example.com", "https://relay-a.example.com"}) {
293
- t.Fatalf("discoveryBootstrapsSnapshot() = %v, want [%q %q]", server.discoveryBootstrapsSnapshot(), "https://bootstrap.example.com", "https://relay-a.example.com")
490
+ if !reflect.DeepEqual(mustRelayAPIURLs(t, server.discoveryCache.AdvertisedDescriptors()), []string{"https://bootstrap.example.com", "https://relay-a.example.com"}) {
491
+ t.Fatalf("AdvertisedDescriptors() = %v, want [%q %q]", mustRelayAPIURLs(t, server.discoveryCache.AdvertisedDescriptors()), "https://bootstrap.example.com", "https://relay-a.example.com")
492
}
493
}
494
portal/wireguard/runtime.go
new
+250
@@ -0,0 +1,250 @@
1
+package wireguard
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net"
8
+ "net/http"
9
+ "net/netip"
10
+ "net/url"
11
+ "strconv"
12
+ "strings"
13
+ "sync"
14
+ "time"
15
+
16
+ "golang.zx2c4.com/wireguard/conn"
17
+ "golang.zx2c4.com/wireguard/device"
18
+ "golang.zx2c4.com/wireguard/tun/netstack"
19
+
20
+ "github.com/gosuda/portal/v2/types"
21
+ "github.com/gosuda/portal/v2/utils"
22
+)
23
+
24
+const (
25
+ DefaultMTU = 1420
26
+ DefaultListenPort = 51820
27
+ DefaultPeerAPIHTTPPort = 7777
28
+ DefaultPersistentKeepalive = 25
29
+ defaultDiscoverRequestTimeout = 15 * time.Second
30
+)
31
+
32
+type RuntimeConfig struct {
33
+ PrivateKey string
34
+ Endpoint string
35
+ OverlayIPv4 string
36
+ MTU int
37
+}
38
+
39
+type Runtime struct {
40
+ device *device.Device
41
+ net *netstack.Net
42
+ overlayIP netip.Addr
43
+
44
+ mu sync.Mutex
45
+ closed bool
46
+}
47
+
48
+func NewRuntime(cfg RuntimeConfig) (*Runtime, error) {
49
+ canonicalPrivateKey, err := utils.NormalizeWireGuardPrivateKey(cfg.PrivateKey)
50
+ if err != nil {
51
+ return nil, fmt.Errorf("normalize wireguard private key: %w", err)
52
+ }
53
+
54
+ listenPort, err := utils.WireGuardListenPort(cfg.Endpoint)
55
+ if err != nil {
56
+ return nil, err
57
+ }
58
+
59
+ overlayIP, err := netip.ParseAddr(strings.TrimSpace(cfg.OverlayIPv4))
60
+ if err != nil || !overlayIP.Is4() {
61
+ return nil, errors.New("overlay ipv4 must be a valid IPv4 address")
62
+ }
63
+
64
+ mtu := cfg.MTU
65
+ if mtu <= 0 {
66
+ mtu = DefaultMTU
67
+ }
68
+
69
+ tunDevice, network, err := netstack.CreateNetTUN([]netip.Addr{overlayIP}, nil, mtu)
70
+ if err != nil {
71
+ return nil, fmt.Errorf("create netstack tun: %w", err)
72
+ }
73
+
74
+ wgDevice := device.NewDevice(tunDevice, conn.NewDefaultBind(), device.NewLogger(device.LogLevelError, "portal-wg"))
75
+ privateKeyHex, err := utils.WireGuardKeyHex(canonicalPrivateKey)
76
+ if err != nil {
77
+ wgDevice.Close()
78
+ <-wgDevice.Wait()
79
+ return nil, err
80
+ }
81
+
82
+ config := fmt.Sprintf("private_key=%s\nlisten_port=%d\n", privateKeyHex, listenPort)
83
+ if err := wgDevice.IpcSet(config); err != nil {
84
+ wgDevice.Close()
85
+ <-wgDevice.Wait()
86
+ return nil, fmt.Errorf("configure wireguard device: %w", err)
87
+ }
88
+ if err := wgDevice.Up(); err != nil {
89
+ wgDevice.Close()
90
+ <-wgDevice.Wait()
91
+ return nil, fmt.Errorf("bring wireguard device up: %w", err)
92
+ }
93
+
94
+ return &Runtime{
95
+ device: wgDevice,
96
+ net: network,
97
+ overlayIP: overlayIP,
98
+ }, nil
99
+}
100
+
101
+func (r *Runtime) ListenTCP(port int) (net.Listener, error) {
102
+ if r == nil || r.net == nil {
103
+ return nil, errors.New("wireguard runtime is not initialized")
104
+ }
105
+ return r.net.ListenTCP(&net.TCPAddr{
106
+ IP: net.ParseIP(r.overlayIP.String()),
107
+ Port: port,
108
+ })
109
+}
110
+
111
+func (r *Runtime) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
112
+ if r == nil || r.net == nil {
113
+ return nil, errors.New("wireguard runtime is not initialized")
114
+ }
115
+ switch network {
116
+ case "tcp", "tcp4", "tcp6":
117
+ default:
118
+ return nil, fmt.Errorf("unsupported network %q", network)
119
+ }
120
+
121
+ host, portText, err := net.SplitHostPort(address)
122
+ if err != nil {
123
+ return nil, err
124
+ }
125
+ ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
126
+ if err != nil {
127
+ return nil, err
128
+ }
129
+ port, err := strconv.Atoi(portText)
130
+ if err != nil || port <= 0 || port > 65535 {
131
+ return nil, errors.New("invalid tcp port")
132
+ }
133
+ return r.net.DialContextTCPAddrPort(ctx, netip.AddrPortFrom(ip, uint16(port)))
134
+}
135
+
136
+func (r *Runtime) Discover(ctx context.Context, overlayIPv4 string, port int, req types.DiscoverRequest) (types.DiscoverResponse, error) {
137
+ if r == nil {
138
+ return types.DiscoverResponse{}, errors.New("wireguard runtime is not initialized")
139
+ }
140
+ if port == 0 {
141
+ port = DefaultPeerAPIHTTPPort
142
+ }
143
+ ip, err := netip.ParseAddr(strings.TrimSpace(overlayIPv4))
144
+ if err != nil || !ip.Is4() {
145
+ return types.DiscoverResponse{}, errors.New("overlay ipv4 must be a valid IPv4 address")
146
+ }
147
+
148
+ baseURL := &url.URL{
149
+ Scheme: "http",
150
+ Host: net.JoinHostPort(ip.String(), strconv.Itoa(port)),
151
+ Path: types.PathDiscovery,
152
+ }
153
+ query := baseURL.Query()
154
+ if req.RootHost != "" {
155
+ query.Set("root_host", req.RootHost)
156
+ }
157
+ if req.Name != "" {
158
+ query.Set("name", req.Name)
159
+ }
160
+ baseURL.RawQuery = query.Encode()
161
+
162
+ httpClient := &http.Client{
163
+ Transport: &http.Transport{
164
+ DialContext: r.DialContext,
165
+ ForceAttemptHTTP2: false,
166
+ },
167
+ Timeout: defaultDiscoverRequestTimeout,
168
+ }
169
+
170
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), nil)
171
+ if err != nil {
172
+ return types.DiscoverResponse{}, err
173
+ }
174
+
175
+ resp, err := httpClient.Do(httpReq)
176
+ if err != nil {
177
+ return types.DiscoverResponse{}, err
178
+ }
179
+ defer resp.Body.Close()
180
+
181
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
182
+ return types.DiscoverResponse{}, utils.DecodeAPIRequestError(resp)
183
+ }
184
+
185
+ envelope, err := utils.DecodeAPIEnvelope[types.DiscoverResponse](resp.Body)
186
+ if err != nil {
187
+ return types.DiscoverResponse{}, fmt.Errorf("decode response: %w", err)
188
+ }
189
+ if !envelope.OK {
190
+ return types.DiscoverResponse{}, utils.NewAPIRequestError(resp.StatusCode, envelope.Error)
191
+ }
192
+ return envelope.Data, nil
193
+}
194
+
195
+func (r *Runtime) ApplyPeers(peers []types.DesiredPeer) error {
196
+ if r == nil || r.device == nil {
197
+ return errors.New("wireguard runtime is not initialized")
198
+ }
199
+
200
+ var builder strings.Builder
201
+ builder.WriteString("replace_peers=true\n")
202
+
203
+ for _, peer := range peers {
204
+ publicKeyHex, err := utils.WireGuardKeyHex(peer.WireGuardPublicKey)
205
+ if err != nil {
206
+ return fmt.Errorf("normalize peer %q public key: %w", peer.RelayID, err)
207
+ }
208
+ builder.WriteString("public_key=")
209
+ builder.WriteString(publicKeyHex)
210
+ builder.WriteByte('\n')
211
+ if endpoint := strings.TrimSpace(peer.WireGuardEndpoint); endpoint != "" {
212
+ builder.WriteString("endpoint=")
213
+ builder.WriteString(endpoint)
214
+ builder.WriteByte('\n')
215
+ }
216
+
217
+ allowedIPs := utils.NormalizeIPPrefixes(peer.AllowedIPs)
218
+ for _, allowedIP := range allowedIPs {
219
+ builder.WriteString("allowed_ip=")
220
+ builder.WriteString(allowedIP)
221
+ builder.WriteByte('\n')
222
+ }
223
+ if DefaultPersistentKeepalive > 0 {
224
+ builder.WriteString("persistent_keepalive_interval=")
225
+ builder.WriteString(strconv.Itoa(DefaultPersistentKeepalive))
226
+ builder.WriteByte('\n')
227
+ }
228
+ }
229
+
230
+ return r.device.IpcSet(builder.String())
231
+}
232
+
233
+func (r *Runtime) Close() error {
234
+ if r == nil || r.device == nil {
235
+ return nil
236
+ }
237
+
238
+ r.mu.Lock()
239
+ if r.closed {
240
+ r.mu.Unlock()
241
+ return nil
242
+ }
243
+ r.closed = true
244
+ device := r.device
245
+ r.mu.Unlock()
246
+
247
+ device.Close()
248
+ <-device.Wait()
249
+ return nil
250
+}
portal/wireguard/runtime_test.go
new
+73
@@ -0,0 +1,73 @@
1
+package wireguard
2
+
3
+import (
4
+ "encoding/base64"
5
+ "net"
6
+ "testing"
7
+
8
+ "github.com/gosuda/portal/v2/utils"
9
+)
10
+
11
+func TestNormalizePrivateKeyAndPublicKeyFromPrivate(t *testing.T) {
12
+ t.Parallel()
13
+
14
+ privateKey, err := utils.NormalizeWireGuardPrivateKey("1111111111111111111111111111111111111111111111111111111111111111")
15
+ if err != nil {
16
+ t.Fatalf("NormalizeWireGuardPrivateKey() error = %v", err)
17
+ }
18
+ if _, err := base64.StdEncoding.DecodeString(privateKey); err != nil {
19
+ t.Fatalf("NormalizeWireGuardPrivateKey() returned non-base64 key: %v", err)
20
+ }
21
+
22
+ publicKey, err := utils.WireGuardPublicKeyFromPrivate(privateKey)
23
+ if err != nil {
24
+ t.Fatalf("WireGuardPublicKeyFromPrivate() error = %v", err)
25
+ }
26
+ decoded, err := base64.StdEncoding.DecodeString(publicKey)
27
+ if err != nil {
28
+ t.Fatalf("WireGuardPublicKeyFromPrivate() returned non-base64 key: %v", err)
29
+ }
30
+ if len(decoded) != 32 {
31
+ t.Fatalf("public key length = %d, want 32", len(decoded))
32
+ }
33
+}
34
+
35
+func TestRuntimeStartAndClose(t *testing.T) {
36
+ t.Parallel()
37
+
38
+ privateKey, err := utils.NormalizeWireGuardPrivateKey("2222222222222222222222222222222222222222222222222222222222222222")
39
+ if err != nil {
40
+ t.Fatalf("NormalizeWireGuardPrivateKey() error = %v", err)
41
+ }
42
+
43
+ port := reserveUDPPort(t)
44
+ runtime, err := NewRuntime(RuntimeConfig{
45
+ PrivateKey: privateKey,
46
+ Endpoint: net.JoinHostPort("127.0.0.1", port),
47
+ OverlayIPv4: "10.77.0.1",
48
+ })
49
+ if err != nil {
50
+ t.Fatalf("NewRuntime() error = %v", err)
51
+ }
52
+ t.Cleanup(func() {
53
+ if err := runtime.Close(); err != nil {
54
+ t.Fatalf("Close() error = %v", err)
55
+ }
56
+ })
57
+}
58
+
59
+func reserveUDPPort(t *testing.T) string {
60
+ t.Helper()
61
+
62
+ conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
63
+ if err != nil {
64
+ t.Fatalf("ListenPacket() error = %v", err)
65
+ }
66
+ defer conn.Close()
67
+
68
+ _, port, err := net.SplitHostPort(conn.LocalAddr().String())
69
+ if err != nil {
70
+ t.Fatalf("SplitHostPort() error = %v", err)
71
+ }
72
+ return port
73
+}
types/api.go
+9
-1
@@ -81,12 +81,20 @@ type DiscoverRequest struct {
81
}
82
83
type DiscoverResponse struct {
84
+ ProtocolVersion uint32 `json:"protocol_version"`
85
+ GeneratedAt time.Time `json:"generated_at"`
86
+ Self RelayDescriptor `json:"self"`
87
+ Peers []RelayDescriptor `json:"peers,omitempty"`
88
+ Service *DiscoveredService `json:"service,omitempty"`
89
+}
90
+
91
+type DiscoveredService struct {
92
Found bool `json:"found"`
93
Name string `json:"name,omitempty"`
94
Hostname string `json:"hostname,omitempty"`
95
ExpiresAt time.Time `json:"expires_at,omitempty"`
96
OwnerAddress string `json:"owner_address,omitempty"`
89
- Bootstraps []string `json:"bootstraps,omitempty"`
97
+ RelayID string `json:"relay_id,omitempty"`
98
}
99
100
type QUICControlMessage struct {
types/discovery.go
new
+67
@@ -0,0 +1,67 @@
1
+package types
2
+
3
+import "time"
4
+
5
+type RelayDescriptor struct {
6
+ RelayID string `json:"relay_id"`
7
+
8
+ OwnerAddress string `json:"owner_address"`
9
+ SignerPublicKey string `json:"signer_public_key"`
10
+
11
+ Sequence uint64 `json:"sequence"`
12
+ Version uint32 `json:"version"`
13
+ IssuedAt time.Time `json:"issued_at"`
14
+ ExpiresAt time.Time `json:"expires_at"`
15
+
16
+ APIHTTPSAddr string `json:"api_https_addr"`
17
+ IngressTLSAddr string `json:"ingress_tls_addr,omitempty"`
18
+
19
+ WireGuardPublicKey string `json:"wireguard_public_key,omitempty"`
20
+ WireGuardEndpoint string `json:"wireguard_endpoint,omitempty"`
21
+ OverlayIPv4 string `json:"overlay_ipv4,omitempty"`
22
+ OverlayCIDRs []string `json:"overlay_cidrs,omitempty"`
23
+
24
+ SupportsTCP bool `json:"supports_tcp,omitempty"`
25
+ SupportsUDP bool `json:"supports_udp,omitempty"`
26
+ SupportsOverlayPeer bool `json:"supports_overlay_peer,omitempty"`
27
+ SupportsWitness bool `json:"supports_witness,omitempty"`
28
+ SupportsVPNExit bool `json:"supports_vpn_exit,omitempty"`
29
+
30
+ StatusState string `json:"status_state,omitempty"`
31
+
32
+ Region string `json:"region,omitempty"`
33
+ Country string `json:"country,omitempty"`
34
+
35
+ ReputationScore float64 `json:"reputation_score,omitempty"`
36
+ WitnessCount uint64 `json:"witness_count,omitempty"`
37
+ MITMSuspectedCount uint64 `json:"mitm_suspected_count,omitempty"`
38
+ MITMQuarantined bool `json:"mitm_quarantined,omitempty"`
39
+
40
+ LastMITMDetectedAt time.Time `json:"last_mitm_detected_at,omitempty"`
41
+
42
+ DescriptorSignature string `json:"descriptor_signature"`
43
+}
44
+
45
+type PeerLifecycleState string
46
+
47
+const (
48
+ PeerStateKnown PeerLifecycleState = "known"
49
+ PeerStateVerified PeerLifecycleState = "verified"
50
+ PeerStateAdvertised PeerLifecycleState = "advertised"
51
+ PeerStateExpired PeerLifecycleState = "expired"
52
+)
53
+
54
+type PeerState struct {
55
+ Descriptor RelayDescriptor `json:"descriptor"`
56
+ State PeerLifecycleState `json:"state"`
57
+ FirstSeenAt time.Time `json:"first_seen_at"`
58
+ LastSeenAt time.Time `json:"last_seen_at"`
59
+ ConsecutiveFailures int `json:"consecutive_failures,omitempty"`
60
+}
61
+
62
+type DesiredPeer struct {
63
+ RelayID string `json:"relay_id"`
64
+ WireGuardPublicKey string `json:"wireguard_public_key"`
65
+ WireGuardEndpoint string `json:"wireguard_endpoint"`
66
+ AllowedIPs []string `json:"allowed_ips,omitempty"`
67
+}
utils/utils.go
+26
@@ -11,6 +11,7 @@ import (
11
"fmt"
12
"io"
13
"net"
14
+ "net/netip"
15
"net/url"
16
"path"
17
"strings"
@@ -536,3 +537,28 @@ func RandomID(prefix string) string {
537
}
538
return prefix + hex.EncodeToString(buf)
539
}
540
+
541
+func NormalizeIPPrefixes(inputs []string) []string {
542
+ if len(inputs) == 0 {
543
+ return nil
544
+ }
545
+ seen := make(map[string]struct{}, len(inputs))
546
+ out := make([]string, 0, len(inputs))
547
+ for _, input := range inputs {
548
+ input = strings.TrimSpace(input)
549
+ if input == "" {
550
+ continue
551
+ }
552
+ prefix, err := netip.ParsePrefix(input)
553
+ if err != nil {
554
+ continue
555
+ }
556
+ normalized := prefix.String()
557
+ if _, ok := seen[normalized]; ok {
558
+ continue
559
+ }
560
+ seen[normalized] = struct{}{}
561
+ out = append(out, normalized)
562
+ }
563
+ return out
564
+}
utils/wireguard.go
new
+105
@@ -0,0 +1,105 @@
1
+package utils
2
+
3
+import (
4
+ "crypto/sha256"
5
+ "encoding/base64"
6
+ "encoding/hex"
7
+ "errors"
8
+ "net"
9
+ "net/netip"
10
+ "strconv"
11
+ "strings"
12
+
13
+ "golang.org/x/crypto/curve25519"
14
+)
15
+
16
+func NormalizeWireGuardPrivateKey(raw string) (string, error) {
17
+ key, err := decodeWireGuardKey(raw)
18
+ if err != nil {
19
+ return "", err
20
+ }
21
+ clampWireGuardPrivateKey(&key)
22
+ return base64.StdEncoding.EncodeToString(key[:]), nil
23
+}
24
+
25
+func WireGuardPublicKeyFromPrivate(raw string) (string, error) {
26
+ privateKey, err := decodeWireGuardKey(raw)
27
+ if err != nil {
28
+ return "", err
29
+ }
30
+ clampWireGuardPrivateKey(&privateKey)
31
+ var publicKey [32]byte
32
+ curve25519.ScalarBaseMult(&publicKey, &privateKey)
33
+ return base64.StdEncoding.EncodeToString(publicKey[:]), nil
34
+}
35
+
36
+func WireGuardListenPort(rawEndpoint string) (int, error) {
37
+ endpoint := strings.TrimSpace(rawEndpoint)
38
+ if endpoint == "" {
39
+ return 0, errors.New("wireguard endpoint is required")
40
+ }
41
+ _, portText, err := net.SplitHostPort(endpoint)
42
+ if err != nil {
43
+ return 0, errors.New("wireguard endpoint must be host:port")
44
+ }
45
+ port, err := strconv.Atoi(portText)
46
+ if err != nil || port <= 0 || port > 65535 {
47
+ return 0, errors.New("wireguard endpoint port is invalid")
48
+ }
49
+ return port, nil
50
+}
51
+
52
+func DeriveWireGuardOverlayIPv4(publicKey string) (string, error) {
53
+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(publicKey))
54
+ if err != nil {
55
+ return "", errors.New("wireguard public key must be base64 encoded")
56
+ }
57
+ if len(decoded) != 32 {
58
+ return "", errors.New("wireguard public key must be 32 bytes")
59
+ }
60
+
61
+ sum := sha256.Sum256(decoded)
62
+ return netip.AddrFrom4([4]byte{
63
+ 100,
64
+ 64 + (sum[0] & 0x3f),
65
+ sum[1],
66
+ 1 + (sum[2] % 254),
67
+ }).String(), nil
68
+}
69
+
70
+func WireGuardKeyHex(raw string) (string, error) {
71
+ key, err := decodeWireGuardKey(raw)
72
+ if err != nil {
73
+ return "", err
74
+ }
75
+ return hex.EncodeToString(key[:]), nil
76
+}
77
+
78
+func decodeWireGuardKey(raw string) ([32]byte, error) {
79
+ var key [32]byte
80
+ value := strings.TrimSpace(raw)
81
+ if value == "" {
82
+ return key, errors.New("wireguard key is required")
83
+ }
84
+
85
+ var decoded []byte
86
+ var err error
87
+ if len(value) == 64 && !strings.Contains(value, "=") {
88
+ decoded, err = hex.DecodeString(value)
89
+ } else {
90
+ decoded, err = base64.StdEncoding.DecodeString(value)
91
+ }
92
+ if err != nil {
93
+ return key, errors.New("wireguard key must be base64 or hex encoded")
94
+ }
95
+ if len(decoded) != len(key) {
96
+ return key, errors.New("wireguard key must be 32 bytes")
97
+ }
98
+ copy(key[:], decoded)
99
+ return key, nil
100
+}
101
+
102
+func clampWireGuardPrivateKey(key *[32]byte) {
103
+ key[0] &= 248
104
+ key[31] = (key[31] & 127) | 64
105
+}