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 +}