main
go 564 lines 15.1 KB
Raw
1 package portal
2
3 import (
4 "context"
5 "crypto/ecdsa"
6 "crypto/elliptic"
7 "crypto/rand"
8 "crypto/tls"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/json"
12 "encoding/pem"
13 "io"
14 "math/big"
15 "net"
16 "net/http"
17 "net/http/httptest"
18 "os"
19 "path/filepath"
20 "strconv"
21 "strings"
22 "sync"
23 "testing"
24 "time"
25
26 "github.com/gosuda/portal-tunnel/v2/portal/acme"
27 "github.com/gosuda/portal-tunnel/v2/types"
28 "github.com/gosuda/portal-tunnel/v2/utils"
29 )
30
31 var (
32 testLeasePortsMu sync.Mutex
33 testLeasePorts = make(map[int]struct{})
34 )
35
36 func tempIdentityPath(t *testing.T) string {
37 t.Helper()
38 return t.TempDir()
39 }
40
41 func tempLeasePort(t *testing.T) int {
42 t.Helper()
43
44 for attempt := 0; attempt < 100; attempt++ {
45 probe, err := net.Listen("tcp", "127.0.0.1:0")
46 if err != nil {
47 t.Fatalf("allocate probe port: %v", err)
48 }
49 _, portText, err := net.SplitHostPort(probe.Addr().String())
50 if closeErr := probe.Close(); closeErr != nil {
51 t.Fatalf("close probe port: %v", closeErr)
52 }
53 if err != nil {
54 t.Fatalf("parse probe port: %v", err)
55 }
56 start, err := strconv.Atoi(portText)
57 if err != nil {
58 t.Fatalf("parse probe port %q: %v", portText, err)
59 }
60 if start <= 0 || start > 65535 {
61 continue
62 }
63 if !reserveTestLeasePort(start) {
64 continue
65 }
66 if tempLeasePortAvailable(start) {
67 return start
68 }
69 releaseTestLeasePort(start)
70 }
71 t.Fatalf("could not find a free lease port")
72 return 0
73 }
74
75 func reserveTestLeasePort(port int) bool {
76 testLeasePortsMu.Lock()
77 defer testLeasePortsMu.Unlock()
78
79 if _, exists := testLeasePorts[port]; exists {
80 return false
81 }
82 testLeasePorts[port] = struct{}{}
83 return true
84 }
85
86 func releaseTestLeasePort(port int) {
87 testLeasePortsMu.Lock()
88 defer testLeasePortsMu.Unlock()
89
90 delete(testLeasePorts, port)
91 }
92
93 func tempLeasePortAvailable(port int) bool {
94 addr := ":" + strconv.Itoa(port)
95 tcpListener, err := net.Listen("tcp", addr)
96 if err != nil {
97 return false
98 }
99 defer tcpListener.Close()
100
101 udpListener, err := net.ListenPacket("udp", addr)
102 if err != nil {
103 return false
104 }
105 defer udpListener.Close()
106
107 return true
108 }
109
110 func newTestClient(t *testing.T, cancel context.CancelFunc, server *Server) *http.Client {
111 t.Helper()
112 client := utils.NewHTTPClient(
113 utils.WithHTTPTLSConfig(&tls.Config{InsecureSkipVerify: true}),
114 )
115 t.Cleanup(func() {
116 client.CloseIdleConnections()
117 cancel()
118 if err := server.Wait(); err != nil {
119 t.Fatalf("Wait() error = %v", err)
120 }
121 })
122 return client
123 }
124
125 func writeManualRelayCertificate(t *testing.T, keyDir, baseDomain string) {
126 t.Helper()
127
128 privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
129 if err != nil {
130 t.Fatalf("GenerateKey() error = %v", err)
131 }
132
133 now := time.Now().UTC()
134 template := &x509.Certificate{
135 SerialNumber: big.NewInt(now.UnixNano()),
136 Subject: pkix.Name{
137 CommonName: baseDomain,
138 },
139 NotBefore: now.Add(-time.Hour),
140 NotAfter: now.Add(90 * 24 * time.Hour),
141 DNSNames: []string{baseDomain, "*." + baseDomain},
142 BasicConstraintsValid: true,
143 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
144 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
145 }
146
147 der, err := x509.CreateCertificate(rand.Reader, template, template, privateKey.Public(), privateKey)
148 if err != nil {
149 t.Fatalf("CreateCertificate() error = %v", err)
150 }
151 keyDER, err := x509.MarshalECPrivateKey(privateKey)
152 if err != nil {
153 t.Fatalf("MarshalECPrivateKey() error = %v", err)
154 }
155
156 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
157 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
158
159 if err := os.WriteFile(filepath.Join(keyDir, "fullchain.pem"), certPEM, 0o644); err != nil {
160 t.Fatalf("WriteFile(cert) error = %v", err)
161 }
162 if err := os.WriteFile(filepath.Join(keyDir, "privatekey.pem"), keyPEM, 0o600); err != nil {
163 t.Fatalf("WriteFile(key) error = %v", err)
164 }
165 }
166
167 func TestRelayDiscoveryEnabledServesDiscoveryEnvelope(t *testing.T) {
168 t.Parallel()
169
170 server, err := NewServer(ServerConfig{
171 PortalURL: "https://portal.example.com",
172 IdentityPath: tempIdentityPath(t),
173 DiscoveryEnabled: true,
174 })
175 if err != nil {
176 t.Fatalf("NewServer() error = %v", err)
177 }
178
179 req := httptest.NewRequest(http.MethodGet, types.PathDiscovery, nil)
180 rec := httptest.NewRecorder()
181 server.handleRelayDiscovery(rec, req)
182
183 if rec.Code != http.StatusOK {
184 t.Fatalf("GET relay discovery status = %d, want %d", rec.Code, http.StatusOK)
185 }
186 var envelope types.APIEnvelope[types.DiscoveryResponse]
187 if err := json.NewDecoder(rec.Body).Decode(&envelope); err != nil {
188 t.Fatalf("json.Decode() error = %v", err)
189 }
190 if !envelope.OK || envelope.Data.ProtocolVersion != types.DiscoveryVersion {
191 t.Fatalf("discovery envelope = %+v, want ok discovery response", envelope)
192 }
193 }
194
195 func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
196 t.Parallel()
197
198 server, err := NewServer(ServerConfig{
199 PortalURL: "https://localhost:4017",
200 IdentityPath: tempIdentityPath(t),
201 ACME: acme.Config{KeyDir: t.TempDir()},
202 APIListenAddr: "127.0.0.1:0",
203 SNIListenAddr: "127.0.0.1:0",
204 MinPort: 40000,
205 MaxPort: 40000,
206 UDPEnabled: true,
207 })
208 if err != nil {
209 t.Fatalf("NewServer() error = %v", err)
210 }
211
212 ctx, cancel := context.WithCancel(context.Background())
213 defer cancel()
214
215 if err := server.Start(ctx, nil); err != nil {
216 t.Fatalf("Start() error = %v", err)
217 }
218
219 client := newTestClient(t, cancel, server)
220
221 healthResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathHealthz)
222 if err != nil {
223 t.Fatalf("GET /api/healthz error = %v", err)
224 }
225 defer healthResp.Body.Close()
226
227 if healthResp.StatusCode != http.StatusOK {
228 t.Fatalf("GET /api/healthz status = %d, want %d", healthResp.StatusCode, http.StatusOK)
229 }
230
231 var healthEnvelope types.APIEnvelope[map[string]string]
232 if err := json.NewDecoder(healthResp.Body).Decode(&healthEnvelope); err != nil {
233 t.Fatalf("decode /api/healthz response: %v", err)
234 }
235 if !healthEnvelope.OK || healthEnvelope.Data["status"] != "ok" {
236 t.Fatalf("GET /api/healthz response = %+v, want ok status", healthEnvelope)
237 }
238
239 signResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathV1Sign)
240 if err != nil {
241 t.Fatalf("GET /v1/sign error = %v", err)
242 }
243 defer signResp.Body.Close()
244
245 if signResp.StatusCode != http.StatusForbidden {
246 t.Fatalf("GET /v1/sign status = %d, want %d", signResp.StatusCode, http.StatusForbidden)
247 }
248 }
249
250 func TestServerStartEnablesPProfOnSeparateHTTPListener(t *testing.T) {
251 t.Parallel()
252
253 server, err := NewServer(ServerConfig{
254 PortalURL: "https://localhost:4017",
255 IdentityPath: tempIdentityPath(t),
256 ACME: acme.Config{KeyDir: t.TempDir()},
257 APIListenAddr: "127.0.0.1:0",
258 SNIListenAddr: "127.0.0.1:0",
259 PProfEnabled: true,
260 PProfListenAddr: "127.0.0.1:0",
261 })
262 if err != nil {
263 t.Fatalf("NewServer() error = %v", err)
264 }
265
266 ctx, cancel := context.WithCancel(context.Background())
267 defer cancel()
268
269 if err := server.Start(ctx, nil); err != nil {
270 t.Fatalf("Start() error = %v", err)
271 }
272
273 client := newTestClient(t, cancel, server)
274 if server.pprofListener == nil {
275 t.Fatal("pprofListener = nil, want listener")
276 }
277
278 resp, err := client.Get("http://" + utils.HostPortOrLoopback(server.pprofListener.Addr().String()) + "/debug/pprof/")
279 if err != nil {
280 t.Fatalf("GET /debug/pprof/ error = %v", err)
281 }
282 defer resp.Body.Close()
283
284 if resp.StatusCode != http.StatusOK {
285 t.Fatalf("GET /debug/pprof/ status = %d, want %d", resp.StatusCode, http.StatusOK)
286 }
287 }
288
289 func TestServerStartDomainReportsCompatibilityInfo(t *testing.T) {
290 t.Parallel()
291
292 server, err := NewServer(ServerConfig{
293 PortalURL: "https://localhost:4017",
294 IdentityPath: tempIdentityPath(t),
295 ACME: acme.Config{KeyDir: t.TempDir()},
296 SNIPort: 4443,
297 APIListenAddr: "127.0.0.1:0",
298 SNIListenAddr: "127.0.0.1:0",
299 })
300 if err != nil {
301 t.Fatalf("NewServer() error = %v", err)
302 }
303
304 ctx, cancel := context.WithCancel(context.Background())
305 defer cancel()
306
307 if err := server.Start(ctx, nil); err != nil {
308 t.Fatalf("Start() error = %v", err)
309 }
310
311 client := newTestClient(t, cancel, server)
312
313 resp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathSDKDomain)
314 if err != nil {
315 t.Fatalf("GET /sdk/domain error = %v", err)
316 }
317 defer resp.Body.Close()
318
319 if resp.StatusCode != http.StatusOK {
320 t.Fatalf("GET /sdk/domain status = %d, want %d", resp.StatusCode, http.StatusOK)
321 }
322
323 body, err := io.ReadAll(resp.Body)
324 if err != nil {
325 t.Fatalf("read /sdk/domain response: %v", err)
326 }
327
328 var envelope types.APIEnvelope[types.DomainResponse]
329 if err := json.Unmarshal(body, &envelope); err != nil {
330 t.Fatalf("decode /sdk/domain response: %v", err)
331 }
332 if !envelope.OK {
333 t.Fatalf("GET /sdk/domain response = %+v, want ok=true", envelope)
334 }
335 if envelope.Data.ProtocolVersion != types.SDKVersion {
336 t.Fatalf("DomainResponse.ProtocolVersion = %q, want %q", envelope.Data.ProtocolVersion, types.SDKVersion)
337 }
338 if envelope.Data.ReleaseVersion != types.ReleaseVersion {
339 t.Fatalf("DomainResponse.ReleaseVersion = %q, want %q", envelope.Data.ReleaseVersion, types.ReleaseVersion)
340 }
341 if envelope.Data.X402.Enabled {
342 t.Fatalf("DomainResponse.X402.Enabled = true, want false")
343 }
344 }
345
346 func TestRegisterLeaseIncludesSNIPortForPublicIngress(t *testing.T) {
347 t.Parallel()
348
349 port := tempLeasePort(t)
350 server, err := NewServer(ServerConfig{
351 PortalURL: "https://portal.example.com:4017",
352 IdentityPath: tempIdentityPath(t),
353 SNIPort: 4443,
354 MinPort: port,
355 MaxPort: port,
356 TCPEnabled: true,
357 })
358 if err != nil {
359 t.Fatalf("NewServer() error = %v", err)
360 }
361
362 record, resp, err := server.registry.Register(types.RegisterChallengeRequest{
363 Identity: types.Identity{
364 Name: "demo-tcp",
365 Address: server.identity.Address,
366 },
367 TCPEnabled: true,
368 }, "203.0.113.10", "")
369 if err != nil {
370 t.Fatalf("registry.Register() error = %v", err)
371 }
372 t.Cleanup(func() {
373 record.Close()
374 })
375
376 if resp.SNIPort != server.config().SNIPort {
377 t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, server.config().SNIPort)
378 }
379 }
380
381 func TestServerStartUsesManualCertificateWithoutACMEProvider(t *testing.T) {
382 t.Parallel()
383
384 keyDir := t.TempDir()
385 writeManualRelayCertificate(t, keyDir, "portal.example.com")
386
387 server, err := NewServer(ServerConfig{
388 PortalURL: "https://portal.example.com",
389 IdentityPath: tempIdentityPath(t),
390 ACME: acme.Config{KeyDir: keyDir},
391 APIListenAddr: "127.0.0.1:0",
392 SNIListenAddr: "127.0.0.1:0",
393 })
394 if err != nil {
395 t.Fatalf("NewServer() error = %v", err)
396 }
397
398 ctx, cancel := context.WithCancel(context.Background())
399 defer cancel()
400
401 if err := server.Start(ctx, nil); err != nil {
402 t.Fatalf("Start() error = %v", err)
403 }
404
405 client := newTestClient(t, cancel, server)
406
407 healthResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathHealthz)
408 if err != nil {
409 t.Fatalf("GET /api/healthz error = %v", err)
410 }
411 defer healthResp.Body.Close()
412
413 if healthResp.StatusCode != http.StatusOK {
414 t.Fatalf("GET /api/healthz status = %d, want %d", healthResp.StatusCode, http.StatusOK)
415 }
416 }
417
418 func TestServerStartRejectsMismatchedACMEBaseDomain(t *testing.T) {
419 t.Parallel()
420
421 server, err := NewServer(ServerConfig{
422 PortalURL: "https://portal.example.com",
423 IdentityPath: tempIdentityPath(t),
424 ACME: acme.Config{BaseDomain: "other.example.com", KeyDir: t.TempDir()},
425 APIListenAddr: "127.0.0.1:0",
426 SNIListenAddr: "127.0.0.1:0",
427 MinPort: 40000,
428 MaxPort: 40000,
429 UDPEnabled: true,
430 })
431 if err != nil {
432 t.Fatalf("NewServer() error = %v", err)
433 }
434
435 err = server.Start(context.Background(), nil)
436 if err == nil {
437 t.Fatal("Start() error = nil, want mismatch error")
438 }
439 if !strings.Contains(err.Error(), "does not match portal root host") {
440 t.Fatalf("Start() error = %v, want base domain mismatch", err)
441 }
442 }
443
444 func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
445 t.Parallel()
446
447 server, err := NewServer(ServerConfig{
448 PortalURL: "https://portal.example.com",
449 IdentityPath: tempIdentityPath(t),
450 MinPort: 40000,
451 MaxPort: 40000,
452 UDPEnabled: true,
453 })
454 if err != nil {
455 t.Fatalf("NewServer() error = %v", err)
456 }
457
458 record, _, err := server.registry.Register(types.RegisterChallengeRequest{
459 Identity: types.Identity{
460 Name: "Demo-App",
461 Address: server.identity.Address,
462 },
463 }, "203.0.113.10", "")
464 if err != nil {
465 t.Fatalf("registry.Register() error = %v", err)
466 }
467
468 wantHostname := "demo-app.portal.example.com"
469 if record.Hostname != wantHostname {
470 t.Fatalf("registry.Register() route hostname = %q, want %q", record.Hostname, wantHostname)
471 }
472
473 lease := server.registry.publicLease(record)
474 if lease.Name != "demo-app" {
475 t.Fatalf("publicLease().Name = %q, want %q", lease.Name, "demo-app")
476 }
477 if lease.Hostname != wantHostname {
478 t.Fatalf("publicLease().Hostname = %q, want %q", lease.Hostname, wantHostname)
479 }
480 }
481
482 func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
483 t.Parallel()
484
485 port := tempLeasePort(t)
486 server, err := NewServer(ServerConfig{
487 PortalURL: "https://portal.example.com",
488 IdentityPath: tempIdentityPath(t),
489 MinPort: port,
490 MaxPort: port,
491 UDPEnabled: true,
492 })
493 if err != nil {
494 t.Fatalf("NewServer() error = %v", err)
495 }
496 server.SetUDPPolicy(true, 0)
497
498 record, resp, err := server.registry.Register(types.RegisterChallengeRequest{
499 Identity: types.Identity{
500 Name: "demo-udp",
501 Address: server.identity.Address,
502 },
503 UDPEnabled: true,
504 }, "203.0.113.10", "")
505 if err != nil {
506 t.Fatalf("registry.Register() error = %v", err)
507 }
508 t.Cleanup(func() {
509 record.Close()
510 })
511
512 if record.stream == nil {
513 t.Fatal("stream = nil, want stream runtime")
514 }
515 if record.datagram == nil {
516 t.Fatal("datagram = nil, want datagram runtime")
517 }
518 if got := record.datagram.UDPPort(); got != port {
519 t.Fatalf("UDPPort() = %d, want %d", got, port)
520 }
521 if resp.SNIPort != server.config().SNIPort {
522 t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, server.config().SNIPort)
523 }
524 if resp.UDPAddr == "" {
525 t.Fatal("RegisterResponse.UDPAddr = empty, want public udp address")
526 }
527 }
528
529 func TestServerStartHidesDiscoveryRoutesWhenDisabled(t *testing.T) {
530 t.Parallel()
531
532 server, err := NewServer(ServerConfig{
533 PortalURL: "https://localhost:4017",
534 IdentityPath: tempIdentityPath(t),
535 ACME: acme.Config{KeyDir: t.TempDir()},
536 APIListenAddr: "127.0.0.1:0",
537 SNIListenAddr: "127.0.0.1:0",
538 })
539 if err != nil {
540 t.Fatalf("NewServer() error = %v", err)
541 }
542
543 ctx, cancel := context.WithCancel(context.Background())
544 defer cancel()
545
546 if err := server.Start(ctx, nil); err != nil {
547 t.Fatalf("Start() error = %v", err)
548 }
549
550 client := newTestClient(t, cancel, server)
551
552 resp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathDiscovery)
553 if err != nil {
554 t.Fatalf("GET relay discovery error = %v", err)
555 }
556 defer resp.Body.Close()
557
558 if resp.StatusCode != http.StatusNotFound {
559 t.Fatalf("GET relay discovery status = %d, want %d", resp.StatusCode, http.StatusNotFound)
560 }
561 if server.config().DiscoveryEnabled {
562 t.Fatal("cfg.DiscoveryEnabled = true, want false without configured discovery service")
563 }
564 }