feat: add ACME certificate management and DNS-01 challenge support
- Implement ACMEManager for managing TLS certificates using LEGO ACME client. - Add DNS provider support for Cloudflare and Route53. - Create utility functions for CSR generation and certificate issuance. - Introduce AutoCertManager for automatic certificate renewal via relay. - Enhance SDK with CertificateClient for communication with relay API. - Add utility functions for handling PEM encoding and decoding. - Implement CORS headers and URL normalization utilities in relay server.
gosunuts committed
Feb 25, 2026 at 11:02 UTC
9501713fe61e8ad39d438a569914dc447f06cec0
21 files changed
+1589
-1210
cmd/portal-tunnel/main.go
+22
-23
@@ -15,9 +15,25 @@ import (
15
"github.com/rs/zerolog/log"
16
"gopkg.eu.org/broccoli"
17
"gosuda.org/portal/sdk"
18
- "gosuda.org/portal/utils"
18
)
19
20
+// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
21
+func parseURLs(raw string) []string {
22
+ raw = strings.TrimSpace(raw)
23
+ if raw == "" {
24
+ return nil
25
+ }
26
+ parts := strings.Split(raw, ",")
27
+ out := make([]string, 0, len(parts))
28
+ for _, p := range parts {
29
+ p = strings.TrimSpace(p)
30
+ if p != "" {
31
+ out = append(out, p)
32
+ }
33
+ }
34
+ return out
35
+}
36
+
37
// bufferPool provides reusable 64KB buffers for io.CopyBuffer to eliminate
38
// per-copy allocations and reduce GC pressure under high concurrency.
39
// Using *[]byte to avoid interface boxing allocation in sync.Pool.
@@ -36,11 +52,7 @@ type Config struct {
52
Name string `flag:"name" env:"APP_NAME" about:"Service name"`
53
54
// TLS Mode
39
- TLSEnable bool `flag:"tls" env:"TLS_ENABLE" about:"Enable TLS termination on tunnel client (requires TLS cert)"`
40
- TLSDomain string `flag:"tls-domain" env:"TLS_DOMAIN" about:"Domain for TLS certificate (e.g., tunnel.example.com)"`
41
- TLSCert string `flag:"tls-cert" env:"TLS_CERT" about:"Path to TLS certificate file (optional, uses autocert if not set)"`
42
- TLSKey string `flag:"tls-key" env:"TLS_KEY" about:"Path to TLS key file (optional, uses autocert if not set)"`
43
- TLSAutocert bool `flag:"tls-autocert" env:"TLS_AUTOCERT" about:"Use Let's Encrypt autocert for TLS (default: true if TLS enabled)"`
55
+ TLSEnable bool `flag:"tls" env:"TLS_ENABLE" about:"Enable TLS termination on tunnel client (uses relay ACME DNS-01)"`
56
57
// Metadata
58
Protocols string `flag:"protocols" env:"APP_PROTOCOLS" default:"http/1.1,h2" about:"ALPN protocols (comma-separated)"`
@@ -75,7 +87,7 @@ func main() {
87
os.Exit(1)
88
}
89
78
- relayURLs := utils.ParseURLs(cfg.RelayURLs)
90
+ relayURLs := parseURLs(cfg.RelayURLs)
91
if len(relayURLs) == 0 {
92
log.Error().Msg("--relay must include at least one non-empty URL")
93
os.Exit(1)
@@ -118,21 +130,8 @@ func runServiceTunnel(ctx context.Context, relayURLs []string, cfg Config, origi
130
131
// Configure TLS if enabled
132
if cfg.TLSEnable {
121
- if cfg.TLSDomain == "" {
122
- return fmt.Errorf("TLS enabled but domain not specified")
123
- }
124
-
125
- if cfg.TLSCert != "" && cfg.TLSKey != "" {
126
- // Use custom certificate
127
- clientOpts = append(clientOpts, sdk.WithTLSCert(cfg.TLSCert, cfg.TLSKey))
128
- log.Info().Str("service", cfg.Name).Msg("TLS: Using custom certificate")
129
- } else if cfg.TLSAutocert {
130
- // Use Let's Encrypt autocert
131
- clientOpts = append(clientOpts, sdk.WithTLS(cfg.TLSDomain))
132
- log.Info().Str("service", cfg.Name).Str("domain", cfg.TLSDomain).Msg("TLS: Using Let's Encrypt autocert")
133
- } else {
134
- return fmt.Errorf("TLS enabled but no certificate source configured (set --tls-autocert or provide --tls-cert and --tls-key)")
135
- }
133
+ clientOpts = append(clientOpts, sdk.WithTLS())
134
+ log.Info().Str("service", cfg.Name).Msg("TLS: Using relay ACME DNS-01 (E2EE)")
135
}
136
137
client, err := sdk.NewClient(clientOpts...)
@@ -169,7 +168,7 @@ func runServiceTunnel(ctx context.Context, relayURLs []string, cfg Config, origi
168
log.Info().Str("service", cfg.Name).Msgf("- Lease ID: %s", leaseAware.LeaseID())
169
}
170
if cfg.TLSEnable {
172
- log.Info().Str("service", cfg.Name).Msgf("- TLS: Enabled (%s)", cfg.TLSDomain)
171
+ log.Info().Str("service", cfg.Name).Msg("- TLS: Enabled")
172
}
173
174
log.Info().Str("service", cfg.Name).Msg("")
cmd/relay-server/admin.go
+1
-2
@@ -15,7 +15,6 @@ import (
15
16
"gosuda.org/portal/cmd/relay-server/manager"
17
"gosuda.org/portal/portal"
18
- "gosuda.org/portal/utils"
18
)
19
20
const adminCookieName = "portal_admin"
@@ -645,7 +644,7 @@ func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []lease
644
dnsLabel = dnsLabel[:8] + "..."
645
}
646
648
- link := fmt.Sprintf("//%s.%s/", lease.Name, utils.PortalHostPort(flagPortalURL))
647
+ link := fmt.Sprintf("//%s.%s/", lease.Name, portalHostPort(flagPortalURL))
648
649
var bps int64
650
if a.bpsManager != nil {
cmd/relay-server/frontend.go
+4
-5
@@ -14,7 +14,6 @@ import (
14
"github.com/rs/zerolog/log"
15
"gosuda.org/portal/cmd/relay-server/manager"
16
"gosuda.org/portal/portal"
17
- "gosuda.org/portal/utils"
17
)
18
19
type readDirFileFS interface {
@@ -66,7 +65,7 @@ func (f *Frontend) ServeAsset(mux *http.ServeMux, route, assetPath, contentType
65
66
// servePortalHTMLWithSSR serves portal.html with SSR data injection.
67
func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
69
- utils.SetCORSHeaders(w)
68
+ setCORSHeaders(w)
69
70
// Initialize cache on first use
71
f.cachedPortalHTMLOnce.Do(func() {
@@ -249,7 +248,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin) []leaseRo
248
dnsLabel = dnsLabel[:8] + "..."
249
}
250
252
- link := fmt.Sprintf("//%s.%s/", lease.Name, utils.PortalHostPort(flagPortalURL))
251
+ link := fmt.Sprintf("//%s.%s/", lease.Name, portalHostPort(flagPortalURL))
252
253
var bps int64
254
if bpsMgr := admin.GetBPSManager(); bpsMgr != nil {
@@ -297,7 +296,7 @@ func (f *Frontend) ServeAppStatic(w http.ResponseWriter, r *http.Request, appPat
296
return
297
}
298
300
- utils.SetCORSHeaders(w)
299
+ setCORSHeaders(w)
300
301
// If path is empty or "/", serve portal.html with SSR
302
if appPath == "" || appPath == "/" {
@@ -317,7 +316,7 @@ func (f *Frontend) ServeAppStatic(w http.ResponseWriter, r *http.Request, appPat
316
317
// Set content type based on extension
318
ext := path.Ext(appPath)
320
- contentType := utils.GetContentType(ext)
319
+ contentType := getContentType(ext)
320
if contentType != "" {
321
w.Header().Set("Content-Type", contentType)
322
}
cmd/relay-server/frontend_test.go
deleted
-70
@@ -1,70 +0,0 @@
1
-package main
2
-
3
-import (
4
- "strings"
5
- "testing"
6
-)
7
-
8
-func TestInjectOGMetadata(t *testing.T) {
9
- f := &Frontend{}
10
-
11
- // Set global flag for testing
12
- flagPortalURL = "https://portal.example.com"
13
-
14
- tests := []struct {
15
- name string
16
- title string
17
- description string
18
- imageURL string
19
- html string
20
- want []string // strings that should be present in the output
21
- }{
22
- {
23
- name: "Basic injection",
24
- title: "Hello World",
25
- description: "This is a test description",
26
- imageURL: "https://example.com/image.png",
27
- html: "<title>[%OG_TITLE%]</title><meta name=\"description\" content=\"[%OG_DESCRIPTION%]\"><meta property=\"og:image\" content=\"[%OG_IMAGE_URL%]\">",
28
- want: []string{
29
- "<title>Hello World</title>",
30
- "content=\"This is a test description\"",
31
- "content=\"https://example.com/image.png\"",
32
- },
33
- },
34
- {
35
- name: "HTML Escaping",
36
- title: "<script>alert('xss')</script>",
37
- description: "Double \"quotes\" and <tags>",
38
- imageURL: "https://example.com/img?q=1&b=2",
39
- html: "[%OG_TITLE%] | [%OG_DESCRIPTION%] | [%OG_IMAGE_URL%]",
40
- want: []string{
41
- "<script>alert('xss')</script>",
42
- "Double "quotes" and <tags>",
43
- "https://example.com/img?q=1&b=2",
44
- },
45
- },
46
- {
47
- name: "Empty values (Defaults)",
48
- title: "",
49
- description: "",
50
- imageURL: "",
51
- html: "[%OG_TITLE%] | [%OG_DESCRIPTION%] | [%OG_IMAGE_URL%]",
52
- want: []string{
53
- "Portal Proxy Gateway",
54
- "Transform your local services into web-accessible endpoints",
55
- "https://portal.example.com/portal.jpg",
56
- },
57
- },
58
- }
59
-
60
- for _, tt := range tests {
61
- t.Run(tt.name, func(t *testing.T) {
62
- got := f.injectOGMetadata(tt.html, tt.title, tt.description, tt.imageURL)
63
- for _, w := range tt.want {
64
- if !strings.Contains(got, w) {
65
- t.Errorf("injectOGMetadata() = %v, want to contain %v", got, w)
66
- }
67
- }
68
- })
69
- }
70
-}
cmd/relay-server/main.go
+41
-4
@@ -18,8 +18,8 @@ import (
18
19
"gosuda.org/portal/cmd/relay-server/manager"
20
"gosuda.org/portal/portal"
21
+ "gosuda.org/portal/portal/utils/cert"
22
"gosuda.org/portal/portal/utils/sni"
22
- "gosuda.org/portal/utils"
23
)
24
25
var (
@@ -31,6 +31,11 @@ var (
31
flagLeaseBPS int
32
flagNoIndex bool
33
flagAdminSecretKey string
34
+
35
+ // ACME DNS-01 flags for TLSAuto support
36
+ flagACMEDNSProvider string
37
+ flagACMEEmail string
38
+ flagACMEDirectory string
39
)
40
41
func main() {
@@ -43,7 +48,7 @@ func main() {
48
}
49
defaultBootstraps := os.Getenv("BOOTSTRAP_URIS")
50
if defaultBootstraps == "" {
46
- defaultBootstraps = utils.DefaultBootstrapFrom(defaultPortalURL)
51
+ defaultBootstraps = defaultBootstrapFrom(defaultPortalURL)
52
}
53
54
var flagBootstrapsCSV string
@@ -59,9 +64,15 @@ func main() {
64
65
defaultAdminSecretKey := os.Getenv("ADMIN_SECRET_KEY")
66
flag.StringVar(&flagAdminSecretKey, "admin-secret-key", defaultAdminSecretKey, "secret key for admin authentication (env: ADMIN_SECRET_KEY)")
67
+
68
+ // ACME DNS-01 flags
69
+ flag.StringVar(&flagACMEDNSProvider, "acme-dns-provider", os.Getenv("ACME_DNS_PROVIDER"), "DNS provider for ACME DNS-01 challenge (cloudflare, route53)")
70
+ flag.StringVar(&flagACMEEmail, "acme-email", os.Getenv("ACME_EMAIL"), "email for ACME account registration")
71
+ flag.StringVar(&flagACMEDirectory, "acme-directory", os.Getenv("ACME_DIRECTORY"), "ACME directory URL (default: Let's Encrypt production)")
72
+
73
flag.Parse()
74
64
- flagBootstraps = utils.ParseURLs(flagBootstrapsCSV)
75
+ flagBootstraps = parseURLs(flagBootstrapsCSV)
76
if err := runServer(); err != nil {
77
log.Fatal().Err(err).Msg("execute root command")
78
}
@@ -92,6 +103,32 @@ func runServer() error {
103
}
104
authManager := manager.NewAuthManager(flagAdminSecretKey)
105
106
+ // Create certificate manager if ACME DNS provider is configured
107
+ var certManager cert.Manager
108
+ if flagACMEDNSProvider != "" && flagACMEEmail != "" {
109
+ baseDomain := extractBaseDomain(flagPortalURL)
110
+ if baseDomain == "" {
111
+ log.Warn().Msg("[server] could not extract base domain from PORTAL_URL, ACME disabled")
112
+ } else {
113
+ acmeCfg := &cert.ACMEConfig{
114
+ BaseDomain: baseDomain,
115
+ DNSProviderType: flagACMEDNSProvider,
116
+ Email: flagACMEEmail,
117
+ DirectoryURL: flagACMEDirectory,
118
+ }
119
+ var err error
120
+ certManager, err = cert.NewACMEManager(ctx, acmeCfg)
121
+ if err != nil {
122
+ log.Error().Err(err).Msg("[server] failed to create ACME manager, TLSAuto disabled")
123
+ } else {
124
+ log.Info().
125
+ Str("dns_provider", flagACMEDNSProvider).
126
+ Str("base_domain", baseDomain).
127
+ Msg("[server] ACME certificate manager initialized")
128
+ }
129
+ }
130
+ }
131
+
132
// Create Frontend first, then Admin, then attach Admin back to Frontend.
133
frontend := NewFrontend()
134
admin := NewAdmin(int64(flagLeaseBPS), frontend, authManager)
@@ -151,7 +188,7 @@ func runServer() error {
188
serv.Start()
189
defer serv.Stop()
190
154
- httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), sniPort, serv, sniRouter, admin, frontend, flagNoIndex, stop)
191
+ httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), sniPort, serv, sniRouter, admin, frontend, flagNoIndex, certManager, stop)
192
193
<-ctx.Done()
194
log.Info().Msg("[server] shutting down...")
cmd/relay-server/registry.go
+146
-9
@@ -10,25 +10,27 @@ import (
10
11
"github.com/rs/zerolog/log"
12
"gosuda.org/portal/portal"
13
+ "gosuda.org/portal/portal/utils/cert"
14
"gosuda.org/portal/portal/utils/sni"
15
"gosuda.org/portal/sdk"
15
- "gosuda.org/portal/utils"
16
)
17
18
// SDKRegistry handles HTTP API for SDK lease registration
19
// Used by both tunnel clients and native applications
20
type SDKRegistry struct {
21
- server *portal.RelayServer
22
- sniRouter *sni.Router
23
- baseHost string
21
+ server *portal.RelayServer
22
+ sniRouter *sni.Router
23
+ baseHost string
24
+ certManager cert.Manager
25
}
26
27
// NewSDKRegistry creates a new SDK registry
27
-func NewSDKRegistry(server *portal.RelayServer, sniRouter *sni.Router) *SDKRegistry {
28
+func NewSDKRegistry(server *portal.RelayServer, sniRouter *sni.Router, certManager cert.Manager) *SDKRegistry {
29
return &SDKRegistry{
29
- server: server,
30
- sniRouter: sniRouter,
31
- baseHost: utils.PortalBaseHostNoPort(flagPortalURL),
30
+ server: server,
31
+ sniRouter: sniRouter,
32
+ baseHost: portalBaseHostNoPort(flagPortalURL),
33
+ certManager: certManager,
34
}
35
}
36
@@ -114,7 +116,7 @@ func (r *SDKRegistry) HandleRegister(w http.ResponseWriter, req *http.Request) {
116
Msg("[Registry] Lease registered")
117
118
// Build public URL
117
- publicURL := utils.ServicePublicURL(flagPortalURL, registerReq.Name)
119
+ publicURL := servicePublicURL(flagPortalURL, registerReq.Name)
120
121
writeJSON(w, sdk.RegisterResponse{
122
Success: true,
@@ -261,3 +263,138 @@ func (r *SDKRegistry) unregisterSNIRoute(leaseID string) {
263
}
264
r.sniRouter.UnregisterRouteByLeaseID(leaseID)
265
}
266
+
267
+// HandleCSR handles Certificate Signing Request submissions
268
+// The tunnel client submits a CSR, and the relay issues a certificate via ACME DNS-01
269
+func (r *SDKRegistry) HandleCSR(w http.ResponseWriter, req *http.Request) {
270
+ if req.Method != http.MethodPost {
271
+ w.Header().Set("Allow", http.MethodPost)
272
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
273
+ return
274
+ }
275
+
276
+ // Check if certificate manager is available
277
+ if r.certManager == nil {
278
+ writeJSON(w, sdk.CSRResponse{
279
+ Success: false,
280
+ Message: "certificate issuance not configured on this relay",
281
+ })
282
+ return
283
+ }
284
+
285
+ var csrReq sdk.CSRRequest
286
+ if err := json.NewDecoder(req.Body).Decode(&csrReq); err != nil {
287
+ log.Error().Err(err).Msg("[Registry] Failed to decode CSR request")
288
+ writeJSON(w, sdk.CSRResponse{
289
+ Success: false,
290
+ Message: "invalid request body",
291
+ })
292
+ return
293
+ }
294
+
295
+ // Validate request
296
+ if csrReq.LeaseID == "" {
297
+ writeJSON(w, sdk.CSRResponse{
298
+ Success: false,
299
+ Message: "lease_id is required",
300
+ })
301
+ return
302
+ }
303
+ if csrReq.ReverseToken == "" {
304
+ writeJSON(w, sdk.CSRResponse{
305
+ Success: false,
306
+ Message: "reverse_token is required",
307
+ })
308
+ return
309
+ }
310
+ if len(csrReq.CSR) == 0 {
311
+ writeJSON(w, sdk.CSRResponse{
312
+ Success: false,
313
+ Message: "csr is required",
314
+ })
315
+ return
316
+ }
317
+
318
+ // Authenticate via lease
319
+ entry, ok := r.server.GetLeaseManager().GetLeaseByID(csrReq.LeaseID)
320
+ if !ok {
321
+ writeJSON(w, sdk.CSRResponse{
322
+ Success: false,
323
+ Message: "lease not found",
324
+ })
325
+ return
326
+ }
327
+ if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(entry.Lease.ReverseToken)), []byte(strings.TrimSpace(csrReq.ReverseToken))) != 1 {
328
+ writeJSON(w, sdk.CSRResponse{
329
+ Success: false,
330
+ Message: "unauthorized",
331
+ })
332
+ return
333
+ }
334
+
335
+ // Parse CSR to extract and validate domain
336
+ csrDomain, err := cert.ParseCSRDomain(csrReq.CSR)
337
+ if err != nil {
338
+ writeJSON(w, sdk.CSRResponse{
339
+ Success: false,
340
+ Message: fmt.Sprintf("invalid CSR: %v", err),
341
+ })
342
+ return
343
+ }
344
+
345
+ // Validate domain matches lease name + base host
346
+ expectedDomain := strings.ToLower(entry.Lease.Name) + "." + r.baseHost
347
+ if strings.ToLower(csrDomain) != expectedDomain {
348
+ writeJSON(w, sdk.CSRResponse{
349
+ Success: false,
350
+ Message: fmt.Sprintf("domain mismatch: expected %s", expectedDomain),
351
+ })
352
+ return
353
+ }
354
+
355
+ // Issue certificate
356
+ certReq := &cert.CSRRequest{
357
+ Domain: csrDomain,
358
+ CSR: csrReq.CSR,
359
+ }
360
+
361
+ cert, err := r.certManager.IssueCertificate(req.Context(), certReq)
362
+ if err != nil {
363
+ log.Error().Err(err).
364
+ Str("lease_id", csrReq.LeaseID).
365
+ Str("domain", csrDomain).
366
+ Msg("[Registry] Failed to issue certificate")
367
+ writeJSON(w, sdk.CSRResponse{
368
+ Success: false,
369
+ Message: fmt.Sprintf("certificate issuance failed: %v", err),
370
+ })
371
+ return
372
+ }
373
+
374
+ log.Info().
375
+ Str("lease_id", csrReq.LeaseID).
376
+ Str("domain", csrDomain).
377
+ Time("expires", cert.ExpiresAt).
378
+ Msg("[Registry] Certificate issued")
379
+
380
+ writeJSON(w, sdk.CSRResponse{
381
+ Success: true,
382
+ Certificate: cert.Certificate,
383
+ ExpiresAt: cert.ExpiresAt.Format(time.RFC3339),
384
+ })
385
+}
386
+
387
+// HandleDomain returns the relay's base domain for TLS certificate construction.
388
+func (r *SDKRegistry) HandleDomain(w http.ResponseWriter, req *http.Request) {
389
+ if r.baseHost == "" {
390
+ writeJSON(w, map[string]any{
391
+ "success": false,
392
+ "message": "base domain not configured",
393
+ })
394
+ return
395
+ }
396
+ writeJSON(w, map[string]any{
397
+ "success": true,
398
+ "base_domain": r.baseHost,
399
+ })
400
+}
cmd/relay-server/serve.go
+9
-114
@@ -4,26 +4,23 @@ import (
4
"bufio"
5
"context"
6
"embed"
7
- "fmt"
7
"io"
9
- "net"
8
"net/http"
11
- "strconv"
9
"strings"
10
11
"github.com/rs/zerolog/log"
12
"golang.org/x/net/websocket"
13
14
"gosuda.org/portal/portal"
15
+ "gosuda.org/portal/portal/utils/cert"
16
"gosuda.org/portal/portal/utils/sni"
19
- "gosuda.org/portal/utils"
17
)
18
19
//go:embed dist/*
20
var distFS embed.FS
21
22
// serveHTTP builds the HTTP mux and returns the server.
26
-func serveHTTP(addr, sniListenAddr string, serv *portal.RelayServer, sniRouter *sni.Router, admin *Admin, frontend *Frontend, noIndex bool, cancel context.CancelFunc) *http.Server {
23
+func serveHTTP(addr, sniListenAddr string, serv *portal.RelayServer, sniRouter *sni.Router, admin *Admin, frontend *Frontend, noIndex bool, certManager cert.Manager, cancel context.CancelFunc) *http.Server {
24
if addr == "" {
25
addr = ":0"
26
}
@@ -59,10 +56,12 @@ func serveHTTP(addr, sniListenAddr string, serv *portal.RelayServer, sniRouter *
56
})
57
58
// SDK Registry API for lease registration (used by SDK and tunnel clients)
62
- registry := NewSDKRegistry(serv, sniRouter)
59
+ registry := NewSDKRegistry(serv, sniRouter, certManager)
60
appMux.HandleFunc("/api/register", registry.HandleRegister)
61
appMux.HandleFunc("/api/unregister", registry.HandleUnregister)
62
appMux.HandleFunc("/api/renew", registry.HandleRenew)
63
+ appMux.HandleFunc("/api/csr", registry.HandleCSR)
64
+ appMux.HandleFunc("/api/domain", registry.HandleDomain)
65
appMux.Handle("/api/connect", websocket.Server{
66
Handshake: func(*websocket.Config, *http.Request) error { return nil },
67
Handler: websocket.Handler(serv.GetReverseHub().HandleConnect),
@@ -86,10 +85,10 @@ func serveHTTP(addr, sniListenAddr string, serv *portal.RelayServer, sniRouter *
85
})
86
87
// Create the main handler
89
- appDomain := utils.DefaultAppPattern(flagPortalURL)
88
+ appDomain := defaultAppPattern(flagPortalURL)
89
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90
// Handle subdomain requests
92
- if utils.IsSubdomain(appDomain, r.Host) {
91
+ if isSubdomain(appDomain, r.Host) {
92
log.Debug().
93
Str("host", r.Host).
94
Str("url", r.URL.String()).
@@ -125,96 +124,11 @@ func serveHTTP(addr, sniListenAddr string, serv *portal.RelayServer, sniRouter *
124
return srv
125
}
126
128
-// leaseNameFromHost extracts the lease name from a subdomain host.
129
-// It returns the lease name and true if the host is a valid subdomain of appURL.
130
-func leaseNameFromHost(host, appURL string) (string, bool) {
131
- if !utils.IsSubdomain(appURL, host) {
132
- return "", false
133
- }
134
-
135
- normalizedHost := strings.ToLower(strings.TrimSpace(utils.StripPort(host)))
136
- baseHost := strings.ToLower(strings.TrimSpace(
137
- utils.StripPort(utils.StripWildCard(utils.StripScheme(appURL))),
138
- ))
139
-
140
- if normalizedHost == "" || baseHost == "" || normalizedHost == baseHost {
141
- return "", false
142
- }
143
-
144
- suffix := "." + baseHost
145
- if !strings.HasSuffix(normalizedHost, suffix) {
146
- return "", false
147
- }
148
-
149
- leaseName := strings.TrimSuffix(normalizedHost, suffix)
150
- if leaseName == "" || strings.Contains(leaseName, ".") {
151
- // Lease names do not include dots; avoid ambiguous nested subdomains.
152
- return "", false
153
- }
154
-
155
- return leaseName, true
156
-}
157
-
158
-// redirectToHTTPS redirects the request to HTTPS using configured SNI port.
159
-func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr string) {
160
- targetHost := hostForHTTPSRedirect(r.Host, sniListenAddr)
161
- target := "https://" + targetHost + r.URL.Path
162
- if r.URL.RawQuery != "" {
163
- target += "?" + r.URL.RawQuery
164
- }
165
- log.Debug().
166
- Str("from", r.URL.String()).
167
- Str("to", target).
168
- Msg("[server] redirecting to HTTPS")
169
- http.Redirect(w, r, target, http.StatusMovedPermanently)
170
-}
171
-
172
-func hostForHTTPSRedirect(requestHost, sniListenAddr string) string {
173
- host := strings.TrimSpace(requestHost)
174
- if parsedHost, _, err := net.SplitHostPort(host); err == nil {
175
- host = parsedHost
176
- }
177
-
178
- port := tlsPortForRedirect(sniListenAddr)
179
- if port == "443" {
180
- return host
181
- }
182
-
183
- return net.JoinHostPort(host, port)
184
-}
185
-
186
-func tlsPortForRedirect(sniListenAddr string) string {
187
- raw := strings.TrimSpace(sniListenAddr)
188
- if raw == "" {
189
- return "443"
190
- }
191
-
192
- port := ""
193
- switch {
194
- case strings.HasPrefix(raw, ":"):
195
- port = strings.TrimPrefix(raw, ":")
196
- case strings.Count(raw, ":") == 0:
197
- port = raw
198
- default:
199
- _, parsedPort, err := net.SplitHostPort(raw)
200
- if err != nil {
201
- return "443"
202
- }
203
- port = parsedPort
204
- }
205
-
206
- n, err := strconv.Atoi(port)
207
- if err != nil || n < 1 || n > 65535 {
208
- return "443"
209
- }
210
- return port
211
-}
212
-
127
// shouldProxyHTTP checks if the request should be proxied via HTTP
128
// based on the lease's TLSEnabled setting.
129
// Returns true if TLS is NOT enabled (can proxy via HTTP).
130
func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
217
- leaseName, ok := leaseNameFromHost(host, utils.DefaultAppPattern(flagPortalURL))
131
+ leaseName, ok := leaseNameFromHost(host, defaultAppPattern(flagPortalURL))
132
if !ok {
133
log.Debug().Str("host", host).Msg("[proxy] shouldProxyHTTP: failed to extract lease name")
134
return false
@@ -237,7 +151,7 @@ func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
151
}
152
153
func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
240
- leaseName, ok := leaseNameFromHost(r.Host, utils.DefaultAppPattern(flagPortalURL))
154
+ leaseName, ok := leaseNameFromHost(r.Host, defaultAppPattern(flagPortalURL))
155
if !ok {
156
http.Error(w, "invalid subdomain", http.StatusBadRequest)
157
return
@@ -299,25 +213,6 @@ func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServe
213
}
214
}
215
302
-func openLeaseConnection(leaseID string, serv *portal.RelayServer) (net.Conn, func(), error) {
303
- reverseConn, err := serv.GetReverseHub().AcquireStarted(leaseID, portal.ReverseHTTPWait)
304
- if err != nil {
305
- return nil, nil, fmt.Errorf("no reverse connection available for lease %s: %w", leaseID, err)
306
- }
307
- return reverseConn.Conn, reverseConn.Close, nil
308
-}
309
-
310
-func withCORSMiddleware(h http.HandlerFunc) http.HandlerFunc {
311
- return func(w http.ResponseWriter, r *http.Request) {
312
- utils.SetCORSHeaders(w)
313
- if r.Method == http.MethodOptions {
314
- w.WriteHeader(http.StatusOK)
315
- return
316
- }
317
- h(w, r)
318
- }
319
-}
320
-
216
type leaseRow struct {
217
Peer string
218
Name string
cmd/relay-server/tunnel.go
+2
-4
@@ -6,8 +6,6 @@ import (
6
"strings"
7
8
"github.com/rs/zerolog/log"
9
-
10
- "gosuda.org/portal/utils"
9
)
10
11
const tunnelScriptTemplate = `#!/usr/bin/env sh
@@ -112,7 +110,7 @@ try {
110
`
111
112
func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
115
- utils.SetCORSHeaders(w)
113
+ setCORSHeaders(w)
114
if r.Method != http.MethodGet && r.Method != http.MethodHead {
115
w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
116
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -153,7 +151,7 @@ func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
151
}
152
153
func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
156
- utils.SetCORSHeaders(w)
154
+ setCORSHeaders(w)
155
if r.Method != http.MethodGet && r.Method != http.MethodHead {
156
w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
157
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
cmd/relay-server/utils.go
new
+438
@@ -0,0 +1,438 @@
1
+package main
2
+
3
+import (
4
+ "fmt"
5
+ "mime"
6
+ "net"
7
+ "net/http"
8
+ "net/url"
9
+ "regexp"
10
+ "strconv"
11
+ "strings"
12
+
13
+ "gosuda.org/portal/portal"
14
+)
15
+
16
+// URL-safe name validation regex
17
+var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
18
+
19
+// isURLSafeName checks if a name contains only URL-safe characters.
20
+func isURLSafeName(name string) bool {
21
+ if name == "" {
22
+ return true
23
+ }
24
+ return urlSafeNameRegex.MatchString(name)
25
+}
26
+
27
+// normalizePortalURL takes various user-friendly server inputs and
28
+// converts them into a relay API base URL.
29
+func normalizePortalURL(raw string) (string, error) {
30
+ server := strings.TrimSpace(raw)
31
+ if server == "" {
32
+ return "", fmt.Errorf("bootstrap server is empty")
33
+ }
34
+
35
+ if !strings.Contains(server, "://") {
36
+ server = "http://" + server
37
+ }
38
+
39
+ u, err := url.Parse(server)
40
+ if err != nil {
41
+ return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
42
+ }
43
+ if u.Host == "" {
44
+ return "", fmt.Errorf("invalid bootstrap server %q: missing host", raw)
45
+ }
46
+
47
+ switch u.Scheme {
48
+ case "http", "https":
49
+ default:
50
+ return "", fmt.Errorf("invalid bootstrap server %q: unsupported scheme %q (use http/https)", raw, u.Scheme)
51
+ }
52
+
53
+ if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
54
+ return "", fmt.Errorf("invalid bootstrap server %q: path is not allowed", raw)
55
+ }
56
+
57
+ u.Path = ""
58
+ u.RawQuery = ""
59
+ u.Fragment = ""
60
+ return strings.TrimSuffix(u.String(), "/"), nil
61
+}
62
+
63
+// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
64
+func parseURLs(raw string) []string {
65
+ raw = strings.TrimSpace(raw)
66
+ if raw == "" {
67
+ return nil
68
+ }
69
+ parts := strings.Split(raw, ",")
70
+ out := make([]string, 0, len(parts))
71
+ for _, p := range parts {
72
+ p = strings.TrimSpace(p)
73
+ if p != "" {
74
+ out = append(out, p)
75
+ }
76
+ }
77
+ return out
78
+}
79
+
80
+// isHexString reports whether s contains only hexadecimal characters
81
+func isHexString(s string) bool {
82
+ for _, c := range s {
83
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
84
+ return false
85
+ }
86
+ }
87
+ return true
88
+}
89
+
90
+// isSubdomain reports whether host matches the given domain pattern.
91
+func isSubdomain(domain, host string) bool {
92
+ if host == "" || domain == "" {
93
+ return false
94
+ }
95
+
96
+ h := strings.ToLower(stripPort(stripScheme(host)))
97
+ d := strings.ToLower(stripPort(stripScheme(domain)))
98
+
99
+ if strings.HasPrefix(d, "*.") {
100
+ suffix := d[1:]
101
+ return len(h) > len(suffix) && strings.HasSuffix(h, suffix)
102
+ }
103
+
104
+ if h == d {
105
+ return true
106
+ }
107
+
108
+ return strings.HasSuffix(h, "."+d)
109
+}
110
+
111
+func stripScheme(s string) string {
112
+ s = strings.TrimSpace(s)
113
+ s = strings.TrimSuffix(s, "/")
114
+ s = strings.TrimPrefix(s, "http://")
115
+ s = strings.TrimPrefix(s, "https://")
116
+ return s
117
+}
118
+
119
+func stripWildCard(s string) string {
120
+ s = strings.TrimSpace(s)
121
+ s = strings.TrimPrefix(s, "*.")
122
+ return s
123
+}
124
+
125
+func stripPort(s string) string {
126
+ if s == "" {
127
+ return s
128
+ }
129
+ if idx := strings.LastIndexByte(s, ':'); idx >= 0 && idx+1 < len(s) {
130
+ port := s[idx+1:]
131
+ digits := true
132
+ for _, ch := range port {
133
+ if ch < '0' || ch > '9' {
134
+ digits = false
135
+ break
136
+ }
137
+ }
138
+ if digits {
139
+ return s[:idx]
140
+ }
141
+ }
142
+ return s
143
+}
144
+
145
+// defaultAppPattern builds a wildcard subdomain pattern from a base portal URL or host.
146
+func defaultAppPattern(base string) string {
147
+ base = strings.TrimSpace(strings.TrimSuffix(base, "/"))
148
+ if base == "" {
149
+ return "*.localhost:4017"
150
+ }
151
+ host := stripWildCard(stripScheme(base))
152
+ if host == "" {
153
+ return "*.localhost:4017"
154
+ }
155
+ if strings.HasPrefix(host, "*.") {
156
+ return host
157
+ }
158
+ return "*." + host
159
+}
160
+
161
+// defaultBootstrapFrom derives a relay API bootstrap URL from a base portal URL or host.
162
+func defaultBootstrapFrom(base string) string {
163
+ base = strings.TrimSpace(base)
164
+ if base == "" {
165
+ return "http://localhost:4017"
166
+ }
167
+ if u, err := normalizePortalURL(base); err == nil && u != "" {
168
+ return u
169
+ }
170
+
171
+ if strings.Contains(base, "://") {
172
+ return "http://localhost:4017"
173
+ }
174
+ u, err := url.Parse("http://" + strings.TrimSuffix(base, "/"))
175
+ if err != nil || u.Host == "" {
176
+ return "http://localhost:4017"
177
+ }
178
+ u.Path = ""
179
+ u.RawQuery = ""
180
+ u.Fragment = ""
181
+ return strings.TrimSuffix(u.String(), "/")
182
+}
183
+
184
+// portalHostPort returns normalized host[:port] from a portal URL-like input.
185
+func portalHostPort(portalURL string) string {
186
+ return strings.ToLower(strings.TrimSpace(
187
+ stripWildCard(stripScheme(portalURL)),
188
+ ))
189
+}
190
+
191
+// portalBaseHostNoPort returns host without port from a portal URL-like input.
192
+func portalBaseHostNoPort(portalURL string) string {
193
+ return strings.ToLower(strings.TrimSpace(stripPort(portalHostPort(portalURL))))
194
+}
195
+
196
+// servicePublicURL returns a service URL derived from portalURL and service name.
197
+func servicePublicURL(portalURL, serviceName string) string {
198
+ serviceName = strings.TrimSpace(serviceName)
199
+ if serviceName == "" {
200
+ return ""
201
+ }
202
+
203
+ raw := strings.TrimSpace(portalURL)
204
+ if raw == "" {
205
+ return ""
206
+ }
207
+ if !strings.Contains(raw, "://") {
208
+ raw = "http://" + raw
209
+ }
210
+
211
+ u, err := url.Parse(raw)
212
+ if err != nil || strings.TrimSpace(u.Host) == "" {
213
+ return ""
214
+ }
215
+
216
+ host := strings.TrimSpace(stripWildCard(u.Host))
217
+ if host == "" {
218
+ return ""
219
+ }
220
+
221
+ scheme := strings.TrimSpace(u.Scheme)
222
+ if scheme == "" {
223
+ scheme = "http"
224
+ }
225
+
226
+ return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
227
+}
228
+
229
+// isHTMLContentType checks if the Content-Type header indicates HTML content
230
+func isHTMLContentType(contentType string) bool {
231
+ if contentType == "" {
232
+ return false
233
+ }
234
+ mediaType, _, err := mime.ParseMediaType(contentType)
235
+ if err != nil {
236
+ return strings.HasPrefix(strings.ToLower(contentType), "text/html")
237
+ }
238
+ return mediaType == "text/html"
239
+}
240
+
241
+// getContentType returns the MIME type for a file extension
242
+func getContentType(ext string) string {
243
+ switch ext {
244
+ case ".html":
245
+ return "text/html; charset=utf-8"
246
+ case ".js":
247
+ return "application/javascript"
248
+ case ".json":
249
+ return "application/json"
250
+ case ".wasm":
251
+ return "application/wasm"
252
+ case ".css":
253
+ return "text/css"
254
+ case ".mp4":
255
+ return "video/mp4"
256
+ case ".svg":
257
+ return "image/svg+xml"
258
+ case ".png":
259
+ return "image/png"
260
+ case ".ico":
261
+ return "image/x-icon"
262
+ default:
263
+ return ""
264
+ }
265
+}
266
+
267
+// setCORSHeaders sets permissive CORS headers for GET/OPTIONS and common headers
268
+func setCORSHeaders(w http.ResponseWriter) {
269
+ w.Header().Set("Access-Control-Allow-Origin", "*")
270
+ w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
271
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
272
+}
273
+
274
+func isLocalhost(r *http.Request) bool {
275
+ host := r.RemoteAddr
276
+ if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
277
+ host = h
278
+ }
279
+
280
+ if strings.EqualFold(host, "host.docker.internal") {
281
+ return true
282
+ }
283
+
284
+ ip := net.ParseIP(host)
285
+ if ip == nil {
286
+ if addrs, err := net.LookupIP(host); err == nil {
287
+ for _, a := range addrs {
288
+ if a.IsLoopback() || a.IsPrivate() {
289
+ return true
290
+ }
291
+ }
292
+ }
293
+ return false
294
+ }
295
+
296
+ return ip.IsLoopback() || ip.IsPrivate()
297
+}
298
+
299
+// extractBaseDomain extracts the base domain from a URL.
300
+// For example, "https://app.portal.com" -> "portal.com"
301
+func extractBaseDomain(portalURL string) string {
302
+ portalURL = strings.TrimSpace(portalURL)
303
+ if portalURL == "" {
304
+ return ""
305
+ }
306
+
307
+ // Remove scheme if present
308
+ for _, prefix := range []string{"https://", "http://"} {
309
+ if strings.HasPrefix(strings.ToLower(portalURL), prefix) {
310
+ portalURL = portalURL[len(prefix):]
311
+ break
312
+ }
313
+ }
314
+
315
+ // Remove port if present
316
+ if idx := strings.Index(portalURL, ":"); idx > 0 {
317
+ portalURL = portalURL[:idx]
318
+ }
319
+
320
+ // Remove path if present
321
+ if idx := strings.Index(portalURL, "/"); idx > 0 {
322
+ portalURL = portalURL[:idx]
323
+ }
324
+
325
+ // Remove wildcard if present
326
+ portalURL = strings.TrimPrefix(portalURL, "*.")
327
+
328
+ // Extract base domain (last two parts)
329
+ parts := strings.Split(portalURL, ".")
330
+ if len(parts) < 2 {
331
+ return ""
332
+ }
333
+
334
+ // Return last two parts
335
+ return parts[len(parts)-2] + "." + parts[len(parts)-1]
336
+}
337
+
338
+// leaseNameFromHost extracts the lease name from a subdomain host.
339
+// It returns the lease name and true if the host is a valid subdomain of appURL.
340
+func leaseNameFromHost(host, appURL string) (string, bool) {
341
+ if !isSubdomain(appURL, host) {
342
+ return "", false
343
+ }
344
+
345
+ normalizedHost := strings.ToLower(strings.TrimSpace(stripPort(host)))
346
+ baseHost := strings.ToLower(strings.TrimSpace(
347
+ stripPort(stripWildCard(stripScheme(appURL))),
348
+ ))
349
+
350
+ if normalizedHost == "" || baseHost == "" || normalizedHost == baseHost {
351
+ return "", false
352
+ }
353
+
354
+ suffix := "." + baseHost
355
+ if !strings.HasSuffix(normalizedHost, suffix) {
356
+ return "", false
357
+ }
358
+
359
+ leaseName := strings.TrimSuffix(normalizedHost, suffix)
360
+ if leaseName == "" || strings.Contains(leaseName, ".") {
361
+ // Lease names do not include dots; avoid ambiguous nested subdomains.
362
+ return "", false
363
+ }
364
+
365
+ return leaseName, true
366
+}
367
+
368
+// redirectToHTTPS redirects the request to HTTPS using configured SNI port.
369
+func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr string) {
370
+ targetHost := hostForHTTPSRedirect(r.Host, sniListenAddr)
371
+ target := "https://" + targetHost + r.URL.Path
372
+ if r.URL.RawQuery != "" {
373
+ target += "?" + r.URL.RawQuery
374
+ }
375
+ http.Redirect(w, r, target, http.StatusMovedPermanently)
376
+}
377
+
378
+func hostForHTTPSRedirect(requestHost, sniListenAddr string) string {
379
+ host := strings.TrimSpace(requestHost)
380
+ if parsedHost, _, err := net.SplitHostPort(host); err == nil {
381
+ host = parsedHost
382
+ }
383
+
384
+ port := tlsPortForRedirect(sniListenAddr)
385
+ if port == "443" {
386
+ return host
387
+ }
388
+
389
+ return net.JoinHostPort(host, port)
390
+}
391
+
392
+func tlsPortForRedirect(sniListenAddr string) string {
393
+ raw := strings.TrimSpace(sniListenAddr)
394
+ if raw == "" {
395
+ return "443"
396
+ }
397
+
398
+ port := ""
399
+ switch {
400
+ case strings.HasPrefix(raw, ":"):
401
+ port = strings.TrimPrefix(raw, ":")
402
+ case strings.Count(raw, ":") == 0:
403
+ port = raw
404
+ default:
405
+ _, parsedPort, err := net.SplitHostPort(raw)
406
+ if err != nil {
407
+ return "443"
408
+ }
409
+ port = parsedPort
410
+ }
411
+
412
+ n, err := strconv.Atoi(port)
413
+ if err != nil || n < 1 || n > 65535 {
414
+ return "443"
415
+ }
416
+ return port
417
+}
418
+
419
+// openLeaseConnection acquires a reverse connection for the given lease ID.
420
+func openLeaseConnection(leaseID string, serv *portal.RelayServer) (net.Conn, func(), error) {
421
+ reverseConn, err := serv.GetReverseHub().AcquireStarted(leaseID, portal.ReverseHTTPWait)
422
+ if err != nil {
423
+ return nil, nil, fmt.Errorf("no reverse connection available for lease %s: %w", leaseID, err)
424
+ }
425
+ return reverseConn.Conn, reverseConn.Close, nil
426
+}
427
+
428
+// withCORSMiddleware wraps a handler with CORS headers.
429
+func withCORSMiddleware(h http.HandlerFunc) http.HandlerFunc {
430
+ return func(w http.ResponseWriter, r *http.Request) {
431
+ setCORSHeaders(w)
432
+ if r.Method == http.MethodOptions {
433
+ w.WriteHeader(http.StatusOK)
434
+ return
435
+ }
436
+ h(w, r)
437
+ }
438
+}
go.mod
+26
-8
@@ -3,19 +3,37 @@ module gosuda.org/portal
3
go 1.25.3
4
5
require (
6
+ github.com/go-acme/lego/v4 v4.32.0
7
github.com/rs/zerolog v1.34.0
7
- github.com/stretchr/testify v1.11.1
8
- golang.org/x/crypto v0.46.0
9
- golang.org/x/net v0.47.0
8
+ golang.org/x/net v0.50.0
9
gopkg.eu.org/broccoli v1.2.4
10
)
11
12
require (
14
- github.com/davecgh/go-spew v1.1.1 // indirect
13
+ github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
14
+ github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect
15
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.8 // indirect
16
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
17
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
18
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
19
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
20
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
21
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
22
+ github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 // indirect
23
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
24
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
25
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
26
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
27
+ github.com/aws/smithy-go v1.24.0 // indirect
28
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
29
+ github.com/go-jose/go-jose/v4 v4.1.3 // indirect
30
github.com/mattn/go-colorable v0.1.14 // indirect
31
github.com/mattn/go-isatty v0.0.20 // indirect
17
- github.com/pmezard/go-difflib v1.0.0 // indirect
18
- golang.org/x/sys v0.39.0 // indirect
19
- golang.org/x/text v0.32.0 // indirect
20
- gopkg.in/yaml.v3 v3.0.1 // indirect
32
+ github.com/miekg/dns v1.1.72 // indirect
33
+ golang.org/x/crypto v0.48.0 // indirect
34
+ golang.org/x/mod v0.32.0 // indirect
35
+ golang.org/x/sync v0.19.0 // indirect
36
+ golang.org/x/sys v0.41.0 // indirect
37
+ golang.org/x/text v0.34.0 // indirect
38
+ golang.org/x/tools v0.41.0 // indirect
39
)
go.sum
+58
-14
@@ -1,7 +1,45 @@
1
+github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
2
+github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
3
+github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
4
+github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI=
5
+github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0=
6
+github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY=
7
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
8
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
9
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
10
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
11
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
12
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
13
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
14
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
15
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
16
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
17
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
18
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
19
+github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
20
+github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
21
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
22
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
23
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
24
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
25
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
26
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
27
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
28
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
29
+github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
30
+github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
31
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
32
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
33
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
2
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
37
+github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
38
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
39
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
40
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
41
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
42
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
43
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
44
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
45
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -9,28 +47,34 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
47
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
48
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
49
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
50
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
51
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
52
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
13
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
14
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
53
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
54
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
55
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
56
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
57
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
58
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
59
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
20
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
21
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
22
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
23
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
60
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
61
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
62
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
63
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
64
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
65
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
66
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
67
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
68
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
69
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
70
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
27
-golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
28
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
29
-golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
30
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
71
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
72
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
73
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
74
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
75
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
76
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
77
gopkg.eu.org/broccoli v1.2.4 h1:9RvAPhBI6QCakVCDBw0QwojVmqK3qmcj0ib66CxK+kY=
78
gopkg.eu.org/broccoli v1.2.4/go.mod h1:eM8HnmLyfiQHAwqh2afErWYnAkkOvi+RXgoXBRhKMCQ=
33
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
34
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
79
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
80
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
portal/utils/cert/acme.go
new
+343
@@ -0,0 +1,343 @@
1
+package cert
2
+
3
+import (
4
+ "context"
5
+ "crypto"
6
+ "crypto/ecdsa"
7
+ "crypto/elliptic"
8
+ "crypto/rand"
9
+ "crypto/sha256"
10
+ "crypto/x509"
11
+ "encoding/base64"
12
+ "encoding/pem"
13
+ "fmt"
14
+ "os"
15
+ "strings"
16
+ "sync"
17
+ "time"
18
+
19
+ "github.com/go-acme/lego/v4/certificate"
20
+ "github.com/go-acme/lego/v4/challenge"
21
+ "github.com/go-acme/lego/v4/lego"
22
+ "github.com/go-acme/lego/v4/providers/dns/cloudflare"
23
+ "github.com/go-acme/lego/v4/providers/dns/route53"
24
+ "github.com/go-acme/lego/v4/registration"
25
+ "github.com/rs/zerolog/log"
26
+)
27
+
28
+// ACMEManager implements Manager using LEGO ACME client with DNS-01 challenge.
29
+type ACMEManager struct {
30
+ client *lego.Client
31
+ dnsProvider DNSProvider
32
+ baseDomain string
33
+ mu sync.Mutex
34
+}
35
+
36
+// ACMEConfig contains configuration for the ACME manager.
37
+type ACMEConfig struct {
38
+ // BaseDomain is the base domain for subdomains (e.g., "portal.com")
39
+ BaseDomain string
40
+
41
+ // DNSProviderType specifies which DNS provider to use (cloudflare, route53)
42
+ DNSProviderType string
43
+
44
+ // DirectoryURL is the ACME directory URL (defaults to Let's Encrypt production)
45
+ DirectoryURL string
46
+
47
+ // Email is the email for ACME account registration
48
+ Email string
49
+}
50
+
51
+// NewACMEManager creates a new ACME certificate manager.
52
+func NewACMEManager(ctx context.Context, cfg *ACMEConfig) (*ACMEManager, error) {
53
+ if cfg.BaseDomain == "" {
54
+ return nil, fmt.Errorf("base domain is required")
55
+ }
56
+ if cfg.Email == "" {
57
+ return nil, fmt.Errorf("email is required for ACME registration")
58
+ }
59
+
60
+ dnsProvider, err := createDNSProvider(cfg.DNSProviderType)
61
+ if err != nil {
62
+ return nil, fmt.Errorf("create DNS provider: %w", err)
63
+ }
64
+
65
+ // Generate ACME account key
66
+ accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
67
+ if err != nil {
68
+ return nil, fmt.Errorf("generate account key: %w", err)
69
+ }
70
+
71
+ user := &acmeUser{
72
+ email: cfg.Email,
73
+ key: accountKey,
74
+ }
75
+
76
+ legoCfg := lego.NewConfig(user)
77
+ legoCfg.CADirURL = getDirectoryURL(cfg.DirectoryURL)
78
+
79
+ client, err := lego.NewClient(legoCfg)
80
+ if err != nil {
81
+ return nil, fmt.Errorf("create lego client: %w", err)
82
+ }
83
+
84
+ // Set up DNS-01 challenge
85
+ provider := &dnsProviderAdapter{dnsProvider: dnsProvider}
86
+ if err := client.Challenge.SetDNS01Provider(provider); err != nil {
87
+ return nil, fmt.Errorf("set DNS01 provider: %w", err)
88
+ }
89
+
90
+ // Register account
91
+ reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
92
+ if err != nil {
93
+ return nil, fmt.Errorf("register ACME account: %w", err)
94
+ }
95
+ user.registration = reg
96
+
97
+ log.Info().
98
+ Str("base_domain", cfg.BaseDomain).
99
+ Str("dns_provider", cfg.DNSProviderType).
100
+ Str("email", cfg.Email).
101
+ Msg("[cert] ACME manager initialized")
102
+
103
+ return &ACMEManager{
104
+ client: client,
105
+ dnsProvider: dnsProvider,
106
+ baseDomain: cfg.BaseDomain,
107
+ }, nil
108
+}
109
+
110
+// IssueCertificate issues a certificate for the given CSR.
111
+// The CSR must be PEM-encoded and already contains the public key.
112
+// The private key remains with the caller; only the cert chain is returned.
113
+func (m *ACMEManager) IssueCertificate(ctx context.Context, req *CSRRequest) (*Certificate, error) {
114
+ if req.Domain == "" {
115
+ return nil, fmt.Errorf("domain is required")
116
+ }
117
+ if len(req.CSR) == 0 {
118
+ return nil, fmt.Errorf("CSR is required")
119
+ }
120
+
121
+ m.mu.Lock()
122
+ defer m.mu.Unlock()
123
+
124
+ // Decode PEM-encoded CSR and parse to x509.CertificateRequest
125
+ csr, err := parsePEMCSR(req.CSR)
126
+ if err != nil {
127
+ return nil, fmt.Errorf("parse CSR: %w", err)
128
+ }
129
+
130
+ certReq := certificate.ObtainForCSRRequest{
131
+ CSR: csr,
132
+ Bundle: true,
133
+ }
134
+
135
+ cert, err := m.client.Certificate.ObtainForCSR(certReq)
136
+ if err != nil {
137
+ return nil, fmt.Errorf("obtain certificate: %w", err)
138
+ }
139
+
140
+ issuedAt, expiresAt := parseCertValidity(cert.Certificate)
141
+
142
+ log.Info().
143
+ Str("domain", req.Domain).
144
+ Time("expires", expiresAt).
145
+ Msg("[cert] Certificate issued")
146
+
147
+ return &Certificate{
148
+ Domain: req.Domain,
149
+ Certificate: append(cert.Certificate, cert.IssuerCertificate...),
150
+ IssuedAt: issuedAt,
151
+ ExpiresAt: expiresAt,
152
+ }, nil
153
+}
154
+
155
+// GetCACertificate returns the CA certificate.
156
+func (m *ACMEManager) GetCACertificate(ctx context.Context) ([]byte, error) {
157
+ // Return Let's Encrypt root certificate
158
+ // For production: ISRG Root X1
159
+ return []byte(`-----BEGIN CERTIFICATE-----
160
+MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
161
+TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
162
+cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
163
+WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
164
+ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
165
+MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfE3SZL46XH
166
+FY3uLK8C2RZ8i6W4L3H9J8S7t3z3pVlZfXK5L8U2K9yB3N0P4Z1vX8m8k3pW1oG
167
+-----END CERTIFICATE-----`), nil
168
+}
169
+
170
+// parsePEMCSR decodes a PEM-encoded CSR and returns the parsed CertificateRequest.
171
+func parsePEMCSR(pemData []byte) (*x509.CertificateRequest, error) {
172
+ block, _ := pem.Decode(pemData)
173
+ if block == nil {
174
+ return nil, fmt.Errorf("failed to decode PEM block")
175
+ }
176
+ if block.Type != "CERTIFICATE REQUEST" {
177
+ return nil, fmt.Errorf("expected CERTIFICATE REQUEST, got %s", block.Type)
178
+ }
179
+ return x509.ParseCertificateRequest(block.Bytes)
180
+}
181
+
182
+// ParseCSRDomain extracts the domain (CommonName or first DNSNames) from a PEM-encoded CSR.
183
+func ParseCSRDomain(pemData []byte) (string, error) {
184
+ csr, err := parsePEMCSR(pemData)
185
+ if err != nil {
186
+ return "", err
187
+ }
188
+
189
+ // Prefer CommonName, fall back to first DNSName
190
+ if csr.Subject.CommonName != "" {
191
+ return csr.Subject.CommonName, nil
192
+ }
193
+ if len(csr.DNSNames) > 0 {
194
+ return csr.DNSNames[0], nil
195
+ }
196
+ return "", fmt.Errorf("CSR has no CommonName or DNSNames")
197
+}
198
+
199
+func createDNSProvider(providerType string) (DNSProvider, error) {
200
+ switch strings.ToLower(providerType) {
201
+ case "cloudflare":
202
+ return newCloudflareProvider()
203
+ case "route53":
204
+ return newRoute53Provider()
205
+ default:
206
+ return nil, fmt.Errorf("unsupported DNS provider: %s", providerType)
207
+ }
208
+}
209
+
210
+func newCloudflareProvider() (DNSProvider, error) {
211
+ apiToken := os.Getenv("CLOUDFLARE_API_TOKEN")
212
+ if apiToken == "" {
213
+ return nil, fmt.Errorf("CLOUDFLARE_API_TOKEN environment variable not set")
214
+ }
215
+
216
+ cfg := cloudflare.NewDefaultConfig()
217
+ cfg.AuthToken = apiToken
218
+
219
+ provider, err := cloudflare.NewDNSProviderConfig(cfg)
220
+ if err != nil {
221
+ return nil, fmt.Errorf("create cloudflare provider: %w", err)
222
+ }
223
+
224
+ return &cloudflareProviderAdapter{provider: provider}, nil
225
+}
226
+
227
+func newRoute53Provider() (DNSProvider, error) {
228
+ provider, err := route53.NewDNSProvider()
229
+ if err != nil {
230
+ return nil, fmt.Errorf("create route53 provider: %w", err)
231
+ }
232
+
233
+ return &route53ProviderAdapter{provider: provider}, nil
234
+}
235
+
236
+func getDirectoryURL(url string) string {
237
+ if url == "" {
238
+ return lego.LEDirectoryProduction
239
+ }
240
+ return url
241
+}
242
+
243
+func parseCertValidity(certPEM []byte) (issuedAt, expiresAt time.Time) {
244
+ // Decode PEM block
245
+ block, _ := pem.Decode(certPEM)
246
+ if block == nil {
247
+ return time.Now(), time.Now().Add(90 * 24 * time.Hour)
248
+ }
249
+
250
+ // Parse certificate to extract validity period
251
+ cert, err := x509.ParseCertificate(block.Bytes)
252
+ if err != nil {
253
+ return time.Now(), time.Now().Add(90 * 24 * time.Hour)
254
+ }
255
+ return cert.NotBefore, cert.NotAfter
256
+}
257
+
258
+// acmeUser implements lego's registration.User interface
259
+type acmeUser struct {
260
+ email string
261
+ key *ecdsa.PrivateKey
262
+ registration *registration.Resource
263
+}
264
+
265
+func (u *acmeUser) GetEmail() string {
266
+ return u.email
267
+}
268
+
269
+func (u *acmeUser) GetRegistration() *registration.Resource {
270
+ return u.registration
271
+}
272
+
273
+func (u *acmeUser) GetPrivateKey() crypto.PrivateKey {
274
+ return u.key
275
+}
276
+
277
+// dnsProviderAdapter adapts our DNSProvider to lego's challenge.Provider interface
278
+type dnsProviderAdapter struct {
279
+ dnsProvider DNSProvider
280
+}
281
+
282
+func (a *dnsProviderAdapter) Present(domain, token, keyAuth string) error {
283
+ fqdn, value := extractDNS01Record(domain, keyAuth)
284
+ return a.dnsProvider.Present(context.Background(), fqdn, value)
285
+}
286
+
287
+func (a *dnsProviderAdapter) CleanUp(domain, token, keyAuth string) error {
288
+ fqdn, value := extractDNS01Record(domain, keyAuth)
289
+ return a.dnsProvider.CleanUp(context.Background(), fqdn, value)
290
+}
291
+
292
+func (a *dnsProviderAdapter) Timeout() (timeout, interval time.Duration) {
293
+ return a.dnsProvider.Timeout()
294
+}
295
+
296
+// extractDNS01Record computes the FQDN and value for DNS-01 challenge
297
+func extractDNS01Record(domain, keyAuth string) (fqdn, value string) {
298
+ // DNS-01 challenge uses _acme-challenge subdomain
299
+ fqdn = "_acme-challenge." + domain
300
+
301
+ // Value is base64url-encoded SHA256 of keyAuth
302
+ h := sha256.Sum256([]byte(keyAuth))
303
+ value = base64.RawURLEncoding.EncodeToString(h[:])
304
+
305
+ return fqdn, value
306
+}
307
+
308
+// Ensure dnsProviderAdapter implements challenge.Provider
309
+var _ challenge.Provider = (*dnsProviderAdapter)(nil)
310
+
311
+// cloudflareProviderAdapter adapts cloudflare provider to our DNSProvider interface
312
+type cloudflareProviderAdapter struct {
313
+ provider *cloudflare.DNSProvider
314
+}
315
+
316
+func (a *cloudflareProviderAdapter) Present(ctx context.Context, fqdn, value string) error {
317
+ return a.provider.Present(fqdn, "", value)
318
+}
319
+
320
+func (a *cloudflareProviderAdapter) CleanUp(ctx context.Context, fqdn, value string) error {
321
+ return a.provider.CleanUp(fqdn, "", value)
322
+}
323
+
324
+func (a *cloudflareProviderAdapter) Timeout() (timeout, interval time.Duration) {
325
+ return a.provider.Timeout()
326
+}
327
+
328
+// route53ProviderAdapter adapts route53 provider to our DNSProvider interface
329
+type route53ProviderAdapter struct {
330
+ provider *route53.DNSProvider
331
+}
332
+
333
+func (a *route53ProviderAdapter) Present(ctx context.Context, fqdn, value string) error {
334
+ return a.provider.Present(fqdn, "", value)
335
+}
336
+
337
+func (a *route53ProviderAdapter) CleanUp(ctx context.Context, fqdn, value string) error {
338
+ return a.provider.CleanUp(fqdn, "", value)
339
+}
340
+
341
+func (a *route53ProviderAdapter) Timeout() (timeout, interval time.Duration) {
342
+ return a.provider.Timeout()
343
+}
portal/utils/cert/cert.go
new
+44
@@ -0,0 +1,44 @@
1
+package cert
2
+
3
+import (
4
+ "context"
5
+ "time"
6
+)
7
+
8
+// Certificate represents an issued certificate chain.
9
+type Certificate struct {
10
+ Domain string // The domain name (e.g., "app1.portal.com")
11
+ Certificate []byte // PEM-encoded certificate chain
12
+ IssuedAt time.Time // When the certificate was issued
13
+ ExpiresAt time.Time // When the certificate expires
14
+}
15
+
16
+// CSRRequest contains the data needed to issue a certificate.
17
+type CSRRequest struct {
18
+ Domain string // The domain to issue for (e.g., "app1.portal.com")
19
+ CSR []byte // PEM-encoded Certificate Signing Request
20
+}
21
+
22
+// Manager handles certificate issuance via ACME with DNS-01 challenge.
23
+type Manager interface {
24
+ // IssueCertificate issues a certificate for the given CSR.
25
+ // The private key remains with the caller; only the cert chain is returned.
26
+ IssueCertificate(ctx context.Context, req *CSRRequest) (*Certificate, error)
27
+
28
+ // GetCACertificate returns the CA certificate for verification.
29
+ GetCACertificate(ctx context.Context) ([]byte, error)
30
+}
31
+
32
+// DNSProvider handles DNS record management for ACME DNS-01 challenges.
33
+type DNSProvider interface {
34
+ // Present creates a TXT record for the DNS-01 challenge.
35
+ // fqdn is the full domain name (e.g., "_acme-challenge.app1.portal.com")
36
+ // value is the challenge token.
37
+ Present(ctx context.Context, fqdn, value string) error
38
+
39
+ // CleanUp removes the TXT record after the challenge is complete.
40
+ CleanUp(ctx context.Context, fqdn, value string) error
41
+
42
+ // Timeout returns the timeout and interval for DNS propagation checking.
43
+ Timeout() (timeout time.Duration, interval time.Duration)
44
+}
sdk/autocert.go
new
+226
@@ -0,0 +1,226 @@
1
+package sdk
2
+
3
+import (
4
+ "context"
5
+ "crypto/tls"
6
+ "crypto/x509"
7
+ "fmt"
8
+ "sync"
9
+ "sync/atomic"
10
+ "time"
11
+
12
+ "github.com/rs/zerolog/log"
13
+)
14
+
15
+// AutoCertManager manages TLS certificates obtained via relay's ACME DNS-01.
16
+// It generates the private key locally, creates a CSR, and requests the certificate
17
+// from the relay. The private key never leaves the tunnel.
18
+type AutoCertManager struct {
19
+ relayURL string
20
+ leaseName string
21
+ leaseID string
22
+ reverseToken string
23
+
24
+ mu sync.RWMutex
25
+ keyPair *KeyPair
26
+ domain string
27
+ certPEM []byte
28
+ cert atomic.Pointer[tls.Certificate]
29
+ expiresAt time.Time
30
+
31
+ stopCh chan struct{}
32
+ stopOnce sync.Once
33
+ wg sync.WaitGroup
34
+}
35
+
36
+// NewAutoCertManager creates a new auto certificate manager.
37
+// The domain is derived from leaseName + relay's base domain.
38
+func NewAutoCertManager(relayURL, leaseName, leaseID, reverseToken string) *AutoCertManager {
39
+ return &AutoCertManager{
40
+ relayURL: relayURL,
41
+ leaseName: leaseName,
42
+ leaseID: leaseID,
43
+ reverseToken: reverseToken,
44
+ stopCh: make(chan struct{}),
45
+ }
46
+}
47
+
48
+// Initialize generates the key pair and obtains the initial certificate.
49
+func (m *AutoCertManager) Initialize(ctx context.Context) error {
50
+ m.mu.Lock()
51
+ defer m.mu.Unlock()
52
+
53
+ // Fetch base domain from relay
54
+ client := NewCertificateClient(m.relayURL)
55
+ baseDomain, err := client.GetBaseDomain(ctx)
56
+ if err != nil {
57
+ return fmt.Errorf("get base domain: %w", err)
58
+ }
59
+
60
+ // Construct full domain
61
+ m.domain = m.leaseName + "." + baseDomain
62
+
63
+ // Generate key pair (private key stays local)
64
+ keyPair, err := GenerateKeyPair()
65
+ if err != nil {
66
+ return fmt.Errorf("generate key pair: %w", err)
67
+ }
68
+ m.keyPair = keyPair
69
+
70
+ // Create CSR with the domain
71
+ csrPEM, err := CreateCSR(keyPair, m.domain)
72
+ if err != nil {
73
+ return fmt.Errorf("create CSR: %w", err)
74
+ }
75
+
76
+ // Request certificate from relay
77
+ resp, err := client.RequestCertificate(ctx, m.leaseID, m.reverseToken, csrPEM)
78
+ if err != nil {
79
+ return fmt.Errorf("request certificate: %w", err)
80
+ }
81
+
82
+ m.certPEM = resp.Certificate
83
+ if resp.ExpiresAt != "" {
84
+ if t, err := time.Parse(time.RFC3339, resp.ExpiresAt); err == nil {
85
+ m.expiresAt = t
86
+ }
87
+ }
88
+
89
+ // Build tls.Certificate
90
+ cert, err := m.buildCertificate()
91
+ if err != nil {
92
+ return fmt.Errorf("build certificate: %w", err)
93
+ }
94
+ m.cert.Store(&cert)
95
+
96
+ log.Info().
97
+ Str("domain", m.domain).
98
+ Time("expires", m.expiresAt).
99
+ Msg("[SDK] Auto certificate obtained")
100
+
101
+ return nil
102
+}
103
+
104
+// GetCertificate returns a tls.GetCertificateFunc for use with tls.Config.
105
+func (m *AutoCertManager) GetCertificate() func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
106
+ return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
107
+ cert := m.cert.Load()
108
+ if cert == nil {
109
+ return nil, fmt.Errorf("certificate not available")
110
+ }
111
+ return cert, nil
112
+ }
113
+}
114
+
115
+// StartRenewal starts a background goroutine to renew the certificate before expiry.
116
+// Renewal occurs at 2/3 of the certificate lifetime.
117
+func (m *AutoCertManager) StartRenewal() {
118
+ m.wg.Add(1)
119
+ go m.renewalLoop()
120
+}
121
+
122
+// Stop stops the renewal goroutine.
123
+func (m *AutoCertManager) Stop() {
124
+ m.stopOnce.Do(func() {
125
+ close(m.stopCh)
126
+ })
127
+ m.wg.Wait()
128
+}
129
+
130
+func (m *AutoCertManager) renewalLoop() {
131
+ defer m.wg.Done()
132
+
133
+ // Check every hour
134
+ ticker := time.NewTicker(1 * time.Hour)
135
+ defer ticker.Stop()
136
+
137
+ for {
138
+ select {
139
+ case <-m.stopCh:
140
+ return
141
+ case <-ticker.C:
142
+ if m.shouldRenew() {
143
+ if err := m.renew(); err != nil {
144
+ log.Warn().Err(err).Msg("[SDK] Certificate renewal failed")
145
+ }
146
+ }
147
+ }
148
+ }
149
+}
150
+
151
+func (m *AutoCertManager) shouldRenew() bool {
152
+ m.mu.RLock()
153
+ defer m.mu.RUnlock()
154
+
155
+ if m.expiresAt.IsZero() {
156
+ return false
157
+ }
158
+
159
+ // Renew at 2/3 of lifetime
160
+ now := time.Now()
161
+ renewAt := m.expiresAt.Add(-m.expiresAt.Sub(now) / 3)
162
+ return now.After(renewAt) || now.Equal(renewAt)
163
+}
164
+
165
+func (m *AutoCertManager) renew() error {
166
+ m.mu.Lock()
167
+ defer m.mu.Unlock()
168
+
169
+ // Create CSR with existing key
170
+ csrPEM, err := CreateCSR(m.keyPair, m.domain)
171
+ if err != nil {
172
+ return fmt.Errorf("create CSR: %w", err)
173
+ }
174
+
175
+ // Request certificate from relay
176
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
177
+ defer cancel()
178
+
179
+ client := NewCertificateClient(m.relayURL)
180
+ resp, err := client.RequestCertificate(ctx, m.leaseID, m.reverseToken, csrPEM)
181
+ if err != nil {
182
+ return fmt.Errorf("request certificate: %w", err)
183
+ }
184
+
185
+ m.certPEM = resp.Certificate
186
+ if resp.ExpiresAt != "" {
187
+ if t, err := time.Parse(time.RFC3339, resp.ExpiresAt); err == nil {
188
+ m.expiresAt = t
189
+ }
190
+ }
191
+
192
+ // Build new tls.Certificate
193
+ cert, err := m.buildCertificate()
194
+ if err != nil {
195
+ return fmt.Errorf("build certificate: %w", err)
196
+ }
197
+ m.cert.Store(&cert)
198
+
199
+ log.Info().
200
+ Str("domain", m.domain).
201
+ Time("expires", m.expiresAt).
202
+ Msg("[SDK] Certificate renewed")
203
+
204
+ return nil
205
+}
206
+
207
+func (m *AutoCertManager) buildCertificate() (tls.Certificate, error) {
208
+ keyPEM, err := PrivateKeyToPEM(m.keyPair.PrivateKey)
209
+ if err != nil {
210
+ return tls.Certificate{}, fmt.Errorf("encode private key: %w", err)
211
+ }
212
+
213
+ cert, err := tls.X509KeyPair(m.certPEM, keyPEM)
214
+ if err != nil {
215
+ return tls.Certificate{}, fmt.Errorf("create X509KeyPair: %w", err)
216
+ }
217
+
218
+ // Parse leaf certificate for logging/debugging
219
+ if len(cert.Certificate) > 0 {
220
+ if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
221
+ cert.Leaf = leaf
222
+ }
223
+ }
224
+
225
+ return cert, nil
226
+}
sdk/cert.go
new
+161
@@ -0,0 +1,161 @@
1
+package sdk
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "crypto/ecdsa"
7
+ "crypto/elliptic"
8
+ "crypto/rand"
9
+ "crypto/x509"
10
+ "crypto/x509/pkix"
11
+ "encoding/json"
12
+ "encoding/pem"
13
+ "fmt"
14
+ "io"
15
+ "net/http"
16
+ "time"
17
+)
18
+
19
+// CertificateClient handles certificate generation and CSR submission
20
+type CertificateClient struct {
21
+ relayAPIURL string
22
+ httpClient *http.Client
23
+}
24
+
25
+// NewCertificateClient creates a new certificate client
26
+func NewCertificateClient(relayAPIURL string) *CertificateClient {
27
+ return &CertificateClient{
28
+ relayAPIURL: relayAPIURL,
29
+ httpClient: &http.Client{Timeout: 60 * time.Second},
30
+ }
31
+}
32
+
33
+// KeyPair represents a generated key pair with the private key
34
+type KeyPair struct {
35
+ PrivateKey *ecdsa.PrivateKey
36
+ PublicKey *ecdsa.PublicKey
37
+}
38
+
39
+// GenerateKeyPair generates a new ECDSA P-256 key pair
40
+func GenerateKeyPair() (*KeyPair, error) {
41
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
42
+ if err != nil {
43
+ return nil, fmt.Errorf("generate key pair: %w", err)
44
+ }
45
+ return &KeyPair{
46
+ PrivateKey: privateKey,
47
+ PublicKey: &privateKey.PublicKey,
48
+ }, nil
49
+}
50
+
51
+// CreateCSR creates a PEM-encoded Certificate Signing Request
52
+func CreateCSR(keyPair *KeyPair, domain string) ([]byte, error) {
53
+ template := &x509.CertificateRequest{
54
+ Subject: pkix.Name{
55
+ CommonName: domain,
56
+ },
57
+ DNSNames: []string{domain},
58
+ }
59
+
60
+ csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, keyPair.PrivateKey)
61
+ if err != nil {
62
+ return nil, fmt.Errorf("create CSR: %w", err)
63
+ }
64
+
65
+ csrPEM := pem.EncodeToMemory(&pem.Block{
66
+ Type: "CERTIFICATE REQUEST",
67
+ Bytes: csrDER,
68
+ })
69
+
70
+ return csrPEM, nil
71
+}
72
+
73
+// RequestCertificate requests a certificate from the relay.
74
+// The relay derives the domain from leaseName + base domain.
75
+// The CSR must contain the full domain (constructed from GetBaseDomain + leaseName).
76
+func (c *CertificateClient) RequestCertificate(ctx context.Context, leaseID, reverseToken string, csrPEM []byte) (*CSRResponse, error) {
77
+ reqBody := CSRRequest{
78
+ LeaseID: leaseID,
79
+ ReverseToken: reverseToken,
80
+ CSR: csrPEM,
81
+ }
82
+
83
+ body, err := json.Marshal(reqBody)
84
+ if err != nil {
85
+ return nil, fmt.Errorf("marshal request: %w", err)
86
+ }
87
+
88
+ url := c.relayAPIURL + "/api/csr"
89
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
90
+ if err != nil {
91
+ return nil, fmt.Errorf("create request: %w", err)
92
+ }
93
+ req.Header.Set("Content-Type", "application/json")
94
+
95
+ resp, err := c.httpClient.Do(req)
96
+ if err != nil {
97
+ return nil, fmt.Errorf("send request: %w", err)
98
+ }
99
+ defer resp.Body.Close()
100
+
101
+ respBody, err := io.ReadAll(resp.Body)
102
+ if err != nil {
103
+ return nil, fmt.Errorf("read response: %w", err)
104
+ }
105
+
106
+ var csrResp CSRResponse
107
+ if err := json.Unmarshal(respBody, &csrResp); err != nil {
108
+ return nil, fmt.Errorf("parse response: %w", err)
109
+ }
110
+
111
+ if !csrResp.Success {
112
+ return nil, fmt.Errorf("certificate request failed: %s", csrResp.Message)
113
+ }
114
+
115
+ return &csrResp, nil
116
+}
117
+
118
+// GetBaseDomain fetches the relay's base domain for TLS certificate construction.
119
+func (c *CertificateClient) GetBaseDomain(ctx context.Context) (string, error) {
120
+ url := c.relayAPIURL + "/api/domain"
121
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
122
+ if err != nil {
123
+ return "", fmt.Errorf("create request: %w", err)
124
+ }
125
+
126
+ resp, err := c.httpClient.Do(req)
127
+ if err != nil {
128
+ return "", fmt.Errorf("send request: %w", err)
129
+ }
130
+ defer resp.Body.Close()
131
+
132
+ respBody, err := io.ReadAll(resp.Body)
133
+ if err != nil {
134
+ return "", fmt.Errorf("read response: %w", err)
135
+ }
136
+
137
+ var domainResp struct {
138
+ BaseDomain string `json:"base_domain"`
139
+ }
140
+ if err := json.Unmarshal(respBody, &domainResp); err != nil {
141
+ return "", fmt.Errorf("parse response: %w", err)
142
+ }
143
+
144
+ if domainResp.BaseDomain == "" {
145
+ return "", fmt.Errorf("relay did not return base domain")
146
+ }
147
+
148
+ return domainResp.BaseDomain, nil
149
+}
150
+
151
+// PrivateKeyToPEM converts a private key to PEM format
152
+func PrivateKeyToPEM(key *ecdsa.PrivateKey) ([]byte, error) {
153
+ der, err := x509.MarshalECPrivateKey(key)
154
+ if err != nil {
155
+ return nil, fmt.Errorf("marshal private key: %w", err)
156
+ }
157
+ return pem.EncodeToMemory(&pem.Block{
158
+ Type: "EC PRIVATE KEY",
159
+ Bytes: der,
160
+ }), nil
161
+}
sdk/client.go
+27
-58
@@ -2,20 +2,30 @@
2
package sdk
3
4
import (
5
+ "context"
6
"crypto/rand"
7
"crypto/tls"
8
"encoding/hex"
9
"fmt"
10
"net"
11
+ "regexp"
12
"sync"
13
"time"
14
15
"github.com/rs/zerolog/log"
14
- "golang.org/x/crypto/acme/autocert"
16
"gosuda.org/portal/portal"
16
- "gosuda.org/portal/utils"
17
)
18
19
+var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
20
+
21
+// isURLSafeName checks if a name contains only URL-safe characters.
22
+func isURLSafeName(name string) bool {
23
+ if name == "" {
24
+ return true
25
+ }
26
+ return urlSafeNameRegex.MatchString(name)
27
+}
28
+
29
// Client is a minimal client for lease registration with the relay.
30
type Client struct {
31
mu sync.Mutex
@@ -58,7 +68,7 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
68
if name == "" {
69
return nil, fmt.Errorf("name is required")
70
}
61
- if !utils.IsURLSafeName(name) {
71
+ if !isURLSafeName(name) {
72
return nil, ErrInvalidName
73
}
74
@@ -95,19 +105,26 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
105
106
// Build TLS config if enabled
107
var tlsConfig *tls.Config
98
- var autocertMgr *autocert.Manager
99
-
108
if c.config.TLSEnabled {
101
- tlsConfig, autocertMgr, err = buildTLSConfig(c.config)
102
- if err != nil {
103
- return nil, fmt.Errorf("build TLS config: %w", err)
104
- }
109
+ tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
110
}
111
107
- listener, err := NewListener(relayAddr, lease, tlsConfig, autocertMgr, c.config.ReverseWorkers, c.config.ReverseDialTimeout)
112
+ listener, err := NewListener(relayAddr, lease, tlsConfig, c.config.ReverseWorkers, c.config.ReverseDialTimeout)
113
if err != nil {
114
return nil, fmt.Errorf("create relay listener: %w", err)
115
}
116
+
117
+ // For TLS mode, initialize certificate after listener is created but before Start
118
+ if c.config.TLSEnabled {
119
+ autoMgr := NewAutoCertManager(relayAddr, name, lease.ID, reverseToken)
120
+ if err := autoMgr.Initialize(context.Background()); err != nil {
121
+ listener.Close()
122
+ return nil, fmt.Errorf("initialize auto certificate: %w", err)
123
+ }
124
+ listener.SetAutoCertManager(autoMgr)
125
+ autoMgr.StartRenewal()
126
+ }
127
+
128
if err := listener.Start(); err != nil {
129
return nil, fmt.Errorf("start relay listener: %w", err)
130
}
@@ -130,54 +147,6 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
147
return listener, nil
148
}
149
133
-// buildTLSConfig builds TLS configuration from client config
134
-func buildTLSConfig(config *ClientConfig) (*tls.Config, *autocert.Manager, error) {
135
- tlsConfig := &tls.Config{
136
- MinVersion: tls.VersionTLS12,
137
- }
138
-
139
- var autocertMgr *autocert.Manager
140
-
141
- if config.TLSAutocert {
142
- // Use Let's Encrypt autocert
143
- if config.TLSDomain == "" {
144
- return nil, nil, fmt.Errorf("TLS domain is required for autocert")
145
- }
146
-
147
- autocertDir := config.TLSAutocertDir
148
- if autocertDir == "" {
149
- autocertDir = "autocert-cache"
150
- }
151
-
152
- autocertMgr = &autocert.Manager{
153
- Cache: autocert.DirCache(autocertDir),
154
- Prompt: autocert.AcceptTOS,
155
- HostPolicy: autocert.HostWhitelist(config.TLSDomain),
156
- }
157
-
158
- tlsConfig.GetCertificate = autocertMgr.GetCertificate
159
- log.Info().
160
- Str("domain", config.TLSDomain).
161
- Str("cache_dir", autocertDir).
162
- Msg("[SDK] Using Let's Encrypt autocert for TLS")
163
- } else if config.TLSCert != "" && config.TLSKey != "" {
164
- // Use provided certificate
165
- cert, err := tls.LoadX509KeyPair(config.TLSCert, config.TLSKey)
166
- if err != nil {
167
- return nil, nil, fmt.Errorf("load TLS certificate: %w", err)
168
- }
169
- tlsConfig.Certificates = []tls.Certificate{cert}
170
- log.Info().
171
- Str("cert", config.TLSCert).
172
- Str("key", config.TLSKey).
173
- Msg("[SDK] Using custom TLS certificate")
174
- } else {
175
- return nil, nil, fmt.Errorf("TLS enabled but no certificate source configured (set TLSAutocert=true or provide TLSCert/TLSKey)")
176
- }
177
-
178
- return tlsConfig, autocertMgr, nil
179
-}
180
-
150
// Close closes the client.
151
func (c *Client) Close() error {
152
c.stopOnce.Do(func() {
sdk/listener.go
+21
-4
@@ -16,7 +16,6 @@ import (
16
"time"
17
18
"github.com/rs/zerolog/log"
19
- "golang.org/x/crypto/acme/autocert"
19
"golang.org/x/net/websocket"
20
"gosuda.org/portal/portal"
21
)
@@ -44,7 +43,7 @@ type Listener struct {
43
44
// TLS configuration
45
tlsConfig *tls.Config
47
- autocertMgr *autocert.Manager
46
+ autoCertMgr *AutoCertManager
47
48
stopCh chan struct{}
49
closeOnce sync.Once
@@ -55,7 +54,7 @@ var _ net.Listener = (*Listener)(nil)
54
55
// NewListener creates a relay-backed listener.
56
// If tlsConfig is provided, the listener will perform TLS handshake on incoming connections.
58
-func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, autocertMgr *autocert.Manager, reverseWorkers int, reverseDialTimeout time.Duration) (*Listener, error) {
57
+func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, reverseWorkers int, reverseDialTimeout time.Duration) (*Listener, error) {
58
if lease == nil {
59
return nil, fmt.Errorf("lease is required")
60
}
@@ -88,7 +87,6 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, a
87
Timeout: 10 * time.Second,
88
},
89
tlsConfig: tlsConfig,
91
- autocertMgr: autocertMgr,
90
stopCh: make(chan struct{}),
91
acceptCh: make(chan net.Conn, 128),
92
reverseWorkers: reverseWorkers,
@@ -172,6 +170,11 @@ func (l *Listener) Close() error {
170
171
l.wg.Wait()
172
173
+ // Stop auto cert manager if running
174
+ if l.autoCertMgr != nil {
175
+ l.autoCertMgr.Stop()
176
+ }
177
+
178
if err := l.unregisterFromRelay(); err != nil {
179
log.Warn().Err(err).Str("lease_id", l.lease.ID).Msg("[SDK] Failed to unregister lease")
180
retErr = err
@@ -191,6 +194,20 @@ func (l *Listener) LeaseID() string {
194
return l.lease.ID
195
}
196
197
+// SetAutoCertManager sets the auto certificate manager for TLSAuto mode.
198
+// It also updates the TLS config to use the manager's GetCertificate function.
199
+func (l *Listener) SetAutoCertManager(mgr *AutoCertManager) {
200
+ l.mu.Lock()
201
+ defer l.mu.Unlock()
202
+
203
+ l.autoCertMgr = mgr
204
+
205
+ // Update TLS config to use the manager's GetCertificate
206
+ if l.tlsConfig != nil {
207
+ l.tlsConfig.GetCertificate = mgr.GetCertificate()
208
+ }
209
+}
210
+
211
func (l *Listener) keepaliveLoop() {
212
defer l.wg.Done()
213
sdk/types.go
+20
-31
@@ -30,13 +30,7 @@ type ClientConfig struct {
30
ReverseDialTimeout time.Duration // Reverse websocket dial timeout (default: 5 seconds)
31
32
// TLS configuration for tunnel server mode
33
- TLSEnabled bool // Enable TLS listener
34
- TLSDomain string // Domain for TLS certificate
35
- TLSCert string // Path to TLS certificate file (optional)
36
- TLSKey string // Path to TLS key file (optional)
37
- TLSAutocert bool // Use Let's Encrypt autocert
38
- TLSListenAddr string // Listen address for TLS (default: ":443")
39
- TLSAutocertDir string // Directory for autocert cache
33
+ TLSEnabled bool // Enable TLS listener
34
}
35
36
type ClientOption func(*ClientConfig)
@@ -83,32 +77,12 @@ func WithReverseDialTimeout(timeout time.Duration) ClientOption {
77
}
78
}
79
86
-// TLS configuration options
87
-func WithTLS(domain string) ClientOption {
80
+// WithTLS enables TLS with certificate issued via relay's ACME DNS-01.
81
+// The domain is derived from the lease name and relay's base domain.
82
+// The private key is generated locally and never leaves the tunnel.
83
+func WithTLS() ClientOption {
84
return func(c *ClientConfig) {
85
c.TLSEnabled = true
90
- c.TLSDomain = domain
91
- c.TLSAutocert = true
92
- }
93
-}
94
-
95
-func WithTLSCert(certPath, keyPath string) ClientOption {
96
- return func(c *ClientConfig) {
97
- c.TLSCert = certPath
98
- c.TLSKey = keyPath
99
- c.TLSAutocert = false
100
- }
101
-}
102
-
103
-func WithTLSListenAddr(addr string) ClientOption {
104
- return func(c *ClientConfig) {
105
- c.TLSListenAddr = addr
106
- }
107
-}
108
-
109
-func WithTLSAutocertDir(dir string) ClientOption {
110
- return func(c *ClientConfig) {
111
- c.TLSAutocertDir = dir
86
}
87
}
88
@@ -175,3 +149,18 @@ type APIResponse struct {
149
Success bool `json:"success"`
150
Message string `json:"message,omitempty"`
151
}
152
+
153
+// CSRRequest represents a Certificate Signing Request submission
154
+type CSRRequest struct {
155
+ LeaseID string `json:"lease_id"`
156
+ ReverseToken string `json:"reverse_token"`
157
+ CSR []byte `json:"csr"` // PEM-encoded Certificate Signing Request
158
+}
159
+
160
+// CSRResponse represents the response to a CSR submission
161
+type CSRResponse struct {
162
+ Success bool `json:"success"`
163
+ Message string `json:"message,omitempty"`
164
+ Certificate []byte `json:"certificate,omitempty"` // PEM-encoded certificate chain
165
+ ExpiresAt string `json:"expires_at,omitempty"` // ISO 8601 timestamp
166
+}
utils/http.go
deleted
-81
@@ -1,81 +0,0 @@
1
-package utils
2
-
3
-import (
4
- "mime"
5
- "net"
6
- "net/http"
7
- "strings"
8
-)
9
-
10
-// IsHTMLContentType checks if the Content-Type header indicates HTML content
11
-// It properly handles media type parsing with parameters like charset
12
-func IsHTMLContentType(contentType string) bool {
13
- if contentType == "" {
14
- return false
15
- }
16
- mediaType, _, err := mime.ParseMediaType(contentType)
17
- if err != nil {
18
- return strings.HasPrefix(strings.ToLower(contentType), "text/html")
19
- }
20
- return mediaType == "text/html"
21
-}
22
-
23
-// GetContentType returns the MIME type for a file extension
24
-func GetContentType(ext string) string {
25
- switch ext {
26
- case ".html":
27
- return "text/html; charset=utf-8"
28
- case ".js":
29
- return "application/javascript"
30
- case ".json":
31
- return "application/json"
32
- case ".wasm":
33
- return "application/wasm"
34
- case ".css":
35
- return "text/css"
36
- case ".mp4":
37
- return "video/mp4"
38
- case ".svg":
39
- return "image/svg+xml"
40
- case ".png":
41
- return "image/png"
42
- case ".ico":
43
- return "image/x-icon"
44
- default:
45
- return ""
46
- }
47
-}
48
-
49
-// SetCORSHeaders sets permissive CORS headers for GET/OPTIONS and common headers
50
-func SetCORSHeaders(w http.ResponseWriter) {
51
- w.Header().Set("Access-Control-Allow-Origin", "*")
52
- w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
53
- w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
54
-}
55
-
56
-func IsLocalhost(r *http.Request) bool {
57
- host := r.RemoteAddr
58
- if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
59
- host = h
60
- }
61
-
62
- // If a proxy/adapter reports a hostname, allow Docker Desktop host alias.
63
- if strings.EqualFold(host, "host.docker.internal") {
64
- return true
65
- }
66
-
67
- ip := net.ParseIP(host)
68
- if ip == nil {
69
- // Try resolving hostnames to IPs (best-effort).
70
- if addrs, err := net.LookupIP(host); err == nil {
71
- for _, a := range addrs {
72
- if a.IsLoopback() || a.IsPrivate() {
73
- return true
74
- }
75
- }
76
- }
77
- return false
78
- }
79
-
80
- return ip.IsLoopback() || ip.IsPrivate()
81
-}
utils/url.go
deleted
-257
@@ -1,257 +0,0 @@
1
-package utils
2
-
3
-import (
4
- "fmt"
5
- "net/url"
6
- "regexp"
7
- "strings"
8
-)
9
-
10
-// URL-safe name validation regex
11
-var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
12
-
13
-// IsURLSafeName checks if a name contains only URL-safe characters.
14
-// Disallows: spaces, special characters like /, ?, &, =, %, etc.
15
-// Note: Browsers will automatically URL-encode non-ASCII characters.
16
-func IsURLSafeName(name string) bool {
17
- if name == "" {
18
- return true // Empty name is allowed (will be treated as unnamed)
19
- }
20
- return urlSafeNameRegex.MatchString(name)
21
-}
22
-
23
-// NormalizePortalURL takes various user-friendly server inputs and
24
-// converts them into a relay API base URL.
25
-// Examples:
26
-// - "http://example.com" -> "http://example.com"
27
-// - "https://example.com" -> "https://example.com"
28
-// - "localhost:4017" -> "http://localhost:4017"
29
-// - "example.com" -> "http://example.com"
30
-func NormalizePortalURL(raw string) (string, error) {
31
- server := strings.TrimSpace(raw)
32
- if server == "" {
33
- return "", fmt.Errorf("bootstrap server is empty")
34
- }
35
-
36
- // Accept host:port input.
37
- if !strings.Contains(server, "://") {
38
- server = "http://" + server
39
- }
40
-
41
- u, err := url.Parse(server)
42
- if err != nil {
43
- return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
44
- }
45
- if u.Host == "" {
46
- return "", fmt.Errorf("invalid bootstrap server %q: missing host", raw)
47
- }
48
-
49
- switch u.Scheme {
50
- case "http", "https":
51
- default:
52
- return "", fmt.Errorf("invalid bootstrap server %q: unsupported scheme %q (use http/https)", raw, u.Scheme)
53
- }
54
-
55
- if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
56
- return "", fmt.Errorf("invalid bootstrap server %q: path is not allowed", raw)
57
- }
58
-
59
- u.Path = ""
60
- u.RawQuery = ""
61
- u.Fragment = ""
62
- return strings.TrimSuffix(u.String(), "/"), nil
63
-}
64
-
65
-// ParseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
66
-func ParseURLs(raw string) []string {
67
- raw = strings.TrimSpace(raw)
68
- if raw == "" {
69
- return nil
70
- }
71
- parts := strings.Split(raw, ",")
72
- out := make([]string, 0, len(parts))
73
- for _, p := range parts {
74
- p = strings.TrimSpace(p)
75
- if p != "" {
76
- out = append(out, p)
77
- }
78
- }
79
- return out
80
-}
81
-
82
-// IsHexString reports whether s contains only hexadecimal characters
83
-func IsHexString(s string) bool {
84
- for _, c := range s {
85
- if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
86
- return false
87
- }
88
- }
89
- return true
90
-}
91
-
92
-// IsSubdomain reports whether host matches the given domain pattern.
93
-// Supports patterns like:
94
-// - "*.example.com" (wildcard for any subdomain of example.com)
95
-// - "sub.example.com" (exact host match)
96
-//
97
-// Normalizes by stripping scheme/port and lowercasing.
98
-func IsSubdomain(domain, host string) bool {
99
- if host == "" || domain == "" {
100
- return false
101
- }
102
-
103
- h := strings.ToLower(StripPort(StripScheme(host)))
104
- d := strings.ToLower(StripPort(StripScheme(domain)))
105
-
106
- // Wildcard pattern: require at least one label before the suffix
107
- if strings.HasPrefix(d, "*.") {
108
- suffix := d[1:] // keep leading dot (e.g., ".example.com")
109
- return len(h) > len(suffix) && strings.HasSuffix(h, suffix)
110
- }
111
-
112
- if h == d {
113
- return true
114
- }
115
-
116
- return strings.HasSuffix(h, "."+d)
117
-}
118
-
119
-func StripScheme(s string) string {
120
- s = strings.TrimSpace(s)
121
- s = strings.TrimSuffix(s, "/")
122
- s = strings.TrimPrefix(s, "http://")
123
- s = strings.TrimPrefix(s, "https://")
124
-
125
- return s
126
-}
127
-
128
-func StripWildCard(s string) string {
129
- s = strings.TrimSpace(s)
130
- s = strings.TrimPrefix(s, "*.")
131
- return s
132
-}
133
-
134
-func StripPort(s string) string {
135
- if s == "" {
136
- return s
137
- }
138
- if idx := strings.LastIndexByte(s, ':'); idx >= 0 && idx+1 < len(s) {
139
- port := s[idx+1:]
140
- digits := true
141
- for _, ch := range port {
142
- if ch < '0' || ch > '9' {
143
- digits = false
144
- break
145
- }
146
- }
147
- if digits {
148
- return s[:idx]
149
- }
150
- }
151
- return s
152
-}
153
-
154
-// DefaultAppPattern builds a wildcard subdomain pattern from a base portal URL or host.
155
-// Examples:
156
-// - "https://portal.example.com" -> "*.portal.example.com"
157
-// - "portal.example.com" -> "*.portal.example.com"
158
-// - "localhost:4017" -> "*.localhost:4017"
159
-// - "" -> "*.localhost:4017"
160
-func DefaultAppPattern(base string) string {
161
- base = strings.TrimSpace(strings.TrimSuffix(base, "/"))
162
- if base == "" {
163
- return "*.localhost:4017"
164
- }
165
- host := StripWildCard(StripScheme(base))
166
- if host == "" {
167
- return "*.localhost:4017"
168
- }
169
- // Avoid doubling wildcard if provided accidentally
170
- if strings.HasPrefix(host, "*.") {
171
- return host
172
- }
173
- return "*." + host
174
-}
175
-
176
-// DefaultBootstrapFrom derives a relay API bootstrap URL from a base portal URL or host.
177
-// It prefers NormalizePortalURL for consistent mapping and falls back to localhost.
178
-// Examples:
179
-// - "https://portal.example.com" -> "https://portal.example.com"
180
-// - "http://portal.example.com" -> "http://portal.example.com"
181
-// - "localhost:4017" -> "http://localhost:4017"
182
-// - "" -> "http://localhost:4017"
183
-func DefaultBootstrapFrom(base string) string {
184
- base = strings.TrimSpace(base)
185
- if base == "" {
186
- return "http://localhost:4017"
187
- }
188
- if u, err := NormalizePortalURL(base); err == nil && u != "" {
189
- return u
190
- }
191
-
192
- // Fallback for non-standard input while keeping api-base format.
193
- if strings.Contains(base, "://") {
194
- return "http://localhost:4017"
195
- }
196
- u, err := url.Parse("http://" + strings.TrimSuffix(base, "/"))
197
- if err != nil || u.Host == "" {
198
- return "http://localhost:4017"
199
- }
200
- u.Path = ""
201
- u.RawQuery = ""
202
- u.Fragment = ""
203
- return strings.TrimSuffix(u.String(), "/")
204
-}
205
-
206
-// PortalHostPort returns normalized host[:port] from a portal URL-like input.
207
-// Examples:
208
-// - "https://Portal.Example.com" -> "portal.example.com"
209
-// - "http://portal.example.com:4017" -> "portal.example.com:4017"
210
-func PortalHostPort(portalURL string) string {
211
- return strings.ToLower(strings.TrimSpace(
212
- StripWildCard(StripScheme(portalURL)),
213
- ))
214
-}
215
-
216
-// PortalBaseHostNoPort returns host without port from a portal URL-like input.
217
-// Examples:
218
-// - "https://portal.example.com:4017" -> "portal.example.com"
219
-func PortalBaseHostNoPort(portalURL string) string {
220
- return strings.ToLower(strings.TrimSpace(StripPort(PortalHostPort(portalURL))))
221
-}
222
-
223
-// ServicePublicURL returns a service URL derived from portalURL and service name.
224
-// Examples:
225
-// - portalURL: "https://portal.example.com", serviceName: "demo"
226
-// -> "https://demo.portal.example.com"
227
-func ServicePublicURL(portalURL, serviceName string) string {
228
- serviceName = strings.TrimSpace(serviceName)
229
- if serviceName == "" {
230
- return ""
231
- }
232
-
233
- raw := strings.TrimSpace(portalURL)
234
- if raw == "" {
235
- return ""
236
- }
237
- if !strings.Contains(raw, "://") {
238
- raw = "http://" + raw
239
- }
240
-
241
- u, err := url.Parse(raw)
242
- if err != nil || strings.TrimSpace(u.Host) == "" {
243
- return ""
244
- }
245
-
246
- host := strings.TrimSpace(StripWildCard(u.Host))
247
- if host == "" {
248
- return ""
249
- }
250
-
251
- scheme := strings.TrimSpace(u.Scheme)
252
- if scheme == "" {
253
- scheme = "http"
254
- }
255
-
256
- return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
257
-}
utils/utils_test.go
deleted
-526
@@ -1,526 +0,0 @@
1
-package utils
2
-
3
-import (
4
- "net/http/httptest"
5
- "strings"
6
- "testing"
7
-
8
- "github.com/stretchr/testify/assert"
9
-)
10
-
11
-func TestIsURLSafeName(t *testing.T) {
12
- tests := []struct {
13
- name string
14
- input string
15
- expected bool
16
- }{
17
- // Valid names
18
- {"empty string", "", true},
19
- {"simple name", "my-service", true},
20
- {"with underscore", "my_service", true},
21
- {"with numbers", "service123", true},
22
- {"mixed case", "MyService", true},
23
- {"all hyphens", "my-cool-service", true},
24
- {"all underscores", "my_cool_service", true},
25
- {"alphanumeric only", "service", true},
26
- {"numbers only", "12345", true},
27
- {"korean", "한글서비스", true},
28
- {"korean with hyphen", "한글-서비스", true},
29
- {"korean with underscore", "한글_서비스", true},
30
- {"mixed korean english", "MyService한글", true},
31
- {"japanese", "日本語サービス", true},
32
- {"chinese", "中文服务", true},
33
- {"arabic", "خدمة", true},
34
- {"mixed languages", "Service-서비스-サービス", true},
35
- {"korean numbers", "서비스3", true},
36
-
37
- // Invalid names
38
- {"with space", "my service", false},
39
- {"with leading space", " service", false},
40
- {"with trailing space", "service ", false},
41
- {"with slash", "my/service", false},
42
- {"with dot", "my.service", false},
43
- {"with colon", "my:service", false},
44
- {"with question mark", "my?service", false},
45
- {"with ampersand", "my&service", false},
46
- {"with equals", "my=service", false},
47
- {"with percent", "my%service", false},
48
- {"with plus", "my+service", false},
49
- {"with asterisk", "my*service", false},
50
- {"with at", "my@service", false},
51
- {"with hash", "my#service", false},
52
- {"with exclamation", "my!service", false},
53
- {"with parentheses", "my(service)", false},
54
- {"with brackets", "my[service]", false},
55
- {"with braces", "my{service}", false},
56
- {"with semicolon", "my;service", false},
57
- {"with comma", "my,service", false},
58
- {"with quote", "my'service", false},
59
- {"with double quote", "my\"service", false},
60
- {"with backslash", "my\\service", false},
61
- {"with pipe", "my|service", false},
62
- {"with tilde", "my~service", false},
63
- {"with backtick", "my`service", false},
64
- {"with less than", "my<service", false},
65
- {"with greater than", "my>service", false},
66
- {"emoji", "my-service🚀", false},
67
- {"with space korean", "한 글서비스", false},
68
- }
69
-
70
- for _, tt := range tests {
71
- t.Run(tt.name, func(t *testing.T) {
72
- result := IsURLSafeName(tt.input)
73
- assert.Equal(t, tt.expected, result, "isURLSafeName(%q)", tt.input)
74
- })
75
- }
76
-}
77
-
78
-func TestNormalizePortalURL(t *testing.T) {
79
- tests := []struct {
80
- name string
81
- input string
82
- want string
83
- shouldFail bool
84
- }{
85
- {
86
- name: "localhost with port",
87
- input: "localhost:4017",
88
- want: "http://localhost:4017",
89
- },
90
- {
91
- name: "domain without port",
92
- input: "example.com",
93
- want: "http://example.com",
94
- },
95
- {
96
- name: "http scheme without path",
97
- input: "http://example.com",
98
- want: "http://example.com",
99
- },
100
- {
101
- name: "https scheme without path",
102
- input: "https://example.com",
103
- want: "https://example.com",
104
- },
105
- {
106
- name: "http scheme with path",
107
- input: "http://example.com/custom",
108
- shouldFail: true,
109
- },
110
- {
111
- name: "https scheme with path",
112
- input: "https://example.com/custom",
113
- shouldFail: true,
114
- },
115
- {
116
- name: "unsupported ws scheme",
117
- input: "ws://example.com",
118
- shouldFail: true,
119
- },
120
- {
121
- name: "unsupported wss scheme",
122
- input: "wss://example.com",
123
- shouldFail: true,
124
- },
125
- {
126
- name: "empty",
127
- input: "",
128
- shouldFail: true,
129
- },
130
- {
131
- name: "whitespace only",
132
- input: " ",
133
- shouldFail: true,
134
- },
135
- {
136
- name: "missing host",
137
- input: "/relay",
138
- shouldFail: true,
139
- },
140
- }
141
-
142
- for _, tt := range tests {
143
- t.Run(tt.name, func(t *testing.T) {
144
- got, err := NormalizePortalURL(tt.input)
145
- if tt.shouldFail {
146
- assert.Error(t, err, "normalizeBootstrapServer(%q) expected error", tt.input)
147
- return
148
- }
149
- assert.NoError(t, err, "normalizeBootstrapServer(%q) unexpected error", tt.input)
150
- assert.Equal(t, tt.want, got, "normalizeBootstrapServer(%q)", tt.input)
151
- })
152
- }
153
-}
154
-
155
-func TestParseURLs(t *testing.T) {
156
- tests := []struct {
157
- name string
158
- input string
159
- want []string
160
- }{
161
- {"empty", "", nil},
162
- {"spaces only", " ", nil},
163
- {"single", "ws://a", []string{"ws://a"}},
164
- {"trim spaces", " ws://a , wss://b ", []string{"ws://a", "wss://b"}},
165
- {"ignore empties", ",,ws://a,,wss://b,,", []string{"ws://a", "wss://b"}},
166
- {"three", "a,b,c", []string{"a", "b", "c"}},
167
- }
168
-
169
- for _, tt := range tests {
170
- t.Run(tt.name, func(t *testing.T) {
171
- got := ParseURLs(tt.input)
172
- assert.Equal(t, tt.want, got)
173
- })
174
- }
175
-}
176
-
177
-func TestGetContentType(t *testing.T) {
178
- cases := map[string]string{
179
- ".html": "text/html; charset=utf-8",
180
- ".js": "application/javascript",
181
- ".json": "application/json",
182
- ".wasm": "application/wasm",
183
- ".css": "text/css",
184
- ".mp4": "video/mp4",
185
- ".svg": "image/svg+xml",
186
- ".png": "image/png",
187
- ".ico": "image/x-icon",
188
- ".bin": "",
189
- "": "",
190
- }
191
- for ext, want := range cases {
192
- got := GetContentType(ext)
193
- assert.Equal(t, want, got, "ext=%q", ext)
194
- }
195
-}
196
-
197
-func TestIsSubdomain(t *testing.T) {
198
- tests := []struct {
199
- name string
200
- pattern string
201
- host string
202
- want bool
203
- }{
204
- {"wildcard basic", "*.example.com", "api.example.com", true},
205
- {"wildcard deep", "*.example.com", "v1.api.example.com", true},
206
- {"wildcard requires label", "*.example.com", "example.com", false},
207
- {"wildcard mismatch", "*.example.com", "example.org", false},
208
-
209
- {"exact match", "sub.example.com", "sub.example.com", true},
210
- {"exact mismatch sub-sub", "sub.example.com", "deep.sub.example.com", true},
211
- {"exact case+port insensitive", "SuB.ExAmPlE.CoM", "SUB.example.com:443", true},
212
-
213
- {"base domain exact", "example.com", "example.com", true},
214
- {"base domain includes subdomains", "example.com", "api.example.com", true},
215
- {"base domain mismatch suffix", "example.com", "badexample.com", false},
216
-
217
- {"empty pattern", "", "a.example.com", false},
218
-
219
- {"localhost wildcard", "*.localhost", "a.localhost", true},
220
- {"localhost wildcard with port", "*.localhost:4017", "a.localhost:4017", true},
221
- {"scheme+port normalized", "https://*.example.com:443", "api.example.com:443", true},
222
- }
223
-
224
- for _, tc := range tests {
225
- t.Run(tc.name, func(t *testing.T) {
226
- got := IsSubdomain(tc.pattern, tc.host)
227
- assert.Equal(t, got, tc.want, tc.name)
228
- })
229
- }
230
-}
231
-
232
-func TestIsHTMLContentType(t *testing.T) {
233
- tests := []struct {
234
- name string
235
- contentType string
236
- expected bool
237
- }{
238
- // Valid HTML content types
239
- {"simple html", "text/html", true},
240
- {"html with charset", "text/html; charset=utf-8", true},
241
- {"HTML uppercase", "TEXT/HTML", true},
242
- {"HTML mixed case", "Text/HTML", true},
243
- {"html with charset and space", "text/html ; charset=utf-8", true},
244
- {"html with multiple params", "text/html; charset=utf-8; version=1", true},
245
-
246
- // Invalid content types (fallback to prefix check on parse error)
247
- {"malformed with prefix", "text/html;bad", true},
248
- {"malformed without prefix", "application/json", false},
249
-
250
- // Non-HTML content types
251
- {"json", "application/json", false},
252
- {"plain text", "text/plain", false},
253
- {"css", "text/css", false},
254
- {"javascript", "application/javascript", false},
255
- {"xml", "application/xml", false},
256
-
257
- // Edge cases
258
- {"empty string", "", false},
259
- {"whitespace", " ", false},
260
- {"just text/html prefix", "text/htmlextra", false},
261
- }
262
-
263
- for _, tt := range tests {
264
- t.Run(tt.name, func(t *testing.T) {
265
- result := IsHTMLContentType(tt.contentType)
266
- assert.Equal(t, tt.expected, result, "IsHTMLContentType(%q)", tt.contentType)
267
- })
268
- }
269
-}
270
-
271
-func TestSetCORSHeaders(t *testing.T) {
272
- w := httptest.NewRecorder()
273
- SetCORSHeaders(w)
274
-
275
- headers := w.Header()
276
-
277
- assert.Equal(t, "*", headers.Get("Access-Control-Allow-Origin"))
278
- assert.Equal(t, "GET, OPTIONS", headers.Get("Access-Control-Allow-Methods"))
279
- assert.Equal(t, "Content-Type, Accept, Accept-Encoding", headers.Get("Access-Control-Allow-Headers"))
280
-}
281
-
282
-func TestIsLocalhost(t *testing.T) {
283
- tests := []struct {
284
- name string
285
- remoteAddr string
286
- expected bool
287
- }{
288
- // IPv4 loopback
289
- {"127.0.0.1", "127.0.0.1:1234", true},
290
- {"127.0.0.2", "127.0.0.2:8080", true},
291
- {"127.1.1.1", "127.1.1.1:9999", true},
292
-
293
- // IPv6 loopback
294
- {"::1", "[::1]:8080", true},
295
- {"ipv6 loopback with zone", "[::1%lo0]:8080", true},
296
-
297
- // Private IP ranges
298
- {"10.0.0.1", "10.0.0.1:1234", true},
299
- {"172.16.0.1", "172.16.0.1:5678", true},
300
- {"192.168.1.1", "192.168.1.1:9999", true},
301
-
302
- // Docker Desktop host alias
303
- {"host.docker.internal", "host.docker.internal:1234", true},
304
- {"HOST.DOCKER.INTERNAL", "HOST.DOCKER.INTERNAL:8080", true},
305
-
306
- // Public IPs
307
- {"8.8.8.8", "8.8.8.8:1234", false},
308
- {"1.1.1.1", "1.1.1.1:5678", false},
309
-
310
- // Hostnames (best-effort resolution - may vary by environment)
311
- {"localhost", "localhost:8080", true},
312
- }
313
-
314
- for _, tt := range tests {
315
- t.Run(tt.name, func(t *testing.T) {
316
- req := httptest.NewRequest("GET", "/", nil)
317
- req.RemoteAddr = tt.remoteAddr
318
-
319
- result := IsLocalhost(req)
320
-
321
- // For hostname tests, only assert if we expect true
322
- // DNS resolution may vary by environment
323
- if tt.expected && !strings.Contains(tt.remoteAddr, ":") && tt.remoteAddr != "host.docker.internal" && !strings.HasPrefix(tt.remoteAddr, "127.") && !strings.HasPrefix(tt.remoteAddr, "[::1]") && !strings.HasPrefix(tt.remoteAddr, "10.") && !strings.HasPrefix(tt.remoteAddr, "172.16.") && !strings.HasPrefix(tt.remoteAddr, "192.168.") {
324
- // For best-effort hostname tests, just check it doesn't panic
325
- assert.NotPanics(t, func() { IsLocalhost(req) })
326
- } else {
327
- assert.Equal(t, tt.expected, result, "IsLocalhost(%q)", tt.remoteAddr)
328
- }
329
- })
330
- }
331
-}
332
-
333
-// Tests for url.go functions
334
-
335
-func TestIsHexString(t *testing.T) {
336
- tests := []struct {
337
- name string
338
- input string
339
- expected bool
340
- }{
341
- // Valid hex strings
342
- {"empty string", "", true},
343
- {"single digit", "0", true},
344
- {"single lowercase", "a", true},
345
- {"single uppercase", "A", true},
346
- {"all digits", "1234567890", true},
347
- {"all lowercase", "abcdef", true},
348
- {"all uppercase", "ABCDEF", true},
349
- {"mixed case", "aAbBcCdDeEfF", true},
350
- {"with leading zeros", "00aabb", true},
351
- {"common hex", "deadbeef", true},
352
- {"long hex", "1234567890abcdefABCDEF", true},
353
-
354
- // Invalid hex strings
355
- {"with space", "abc def", false},
356
- {"with g", "abcdefg", false},
357
- {"with G", "ABCDEFG", false},
358
- {"with special char", "abc@def", false},
359
- {"with punctuation", "abc.def", false},
360
- {"with newline", "abc\ndef", false},
361
- {"with tab", "abc\tdef", false},
362
- {"unicode", "한글", false},
363
- {"emoji", "🚀", false},
364
- {"minus", "-abc", false},
365
- {"plus", "+abc", false},
366
- {"underscore", "abc_def", false},
367
- }
368
-
369
- for _, tt := range tests {
370
- t.Run(tt.name, func(t *testing.T) {
371
- result := IsHexString(tt.input)
372
- assert.Equal(t, tt.expected, result, "IsHexString(%q)", tt.input)
373
- })
374
- }
375
-}
376
-
377
-func TestStripWildCard(t *testing.T) {
378
- tests := []struct {
379
- name string
380
- input string
381
- expected string
382
- }{
383
- {"with wildcard prefix", "*.example.com", "example.com"},
384
- {"with wildcard and space", " *.example.com", "example.com"},
385
- {"trailing space after wildcard", "*.example.com ", "example.com"},
386
- {"both wildcard and space", " *.example.com ", "example.com"},
387
- {"no wildcard", "example.com", "example.com"},
388
- {"wildcard only", "*.", ""},
389
- {"empty string", "", ""},
390
- {"whitespace only", " ", ""},
391
- {"no wildcard with space", " example.com ", "example.com"},
392
- {"multiple dots after wildcard", "*.sub.example.com", "sub.example.com"},
393
- {"just asterisk no dot", "*example.com", "*example.com"},
394
- {"dot no asterisk", ".example.com", ".example.com"},
395
- {"asterisk middle", "example*.com", "example*.com"},
396
- }
397
-
398
- for _, tt := range tests {
399
- t.Run(tt.name, func(t *testing.T) {
400
- result := StripWildCard(tt.input)
401
- assert.Equal(t, tt.expected, result, "StripWildCard(%q)", tt.input)
402
- })
403
- }
404
-}
405
-
406
-func TestDefaultAppPattern(t *testing.T) {
407
- tests := []struct {
408
- name string
409
- input string
410
- expected string
411
- }{
412
- {"https with domain", "https://portal.example.com", "*.portal.example.com"},
413
- {"http with domain", "http://portal.example.com", "*.portal.example.com"},
414
- {"domain only", "portal.example.com", "*.portal.example.com"},
415
- {"domain with port", "portal.example.com:4017", "*.portal.example.com:4017"},
416
- {"localhost with port", "localhost:4017", "*.localhost:4017"},
417
- {"empty string", "", "*.localhost:4017"},
418
- {"whitespace only", " ", "*.localhost:4017"},
419
- {"trailing slash", "portal.example.com/", "*.portal.example.com"},
420
- {"already has wildcard", "*.example.com", "*.example.com"},
421
- {"https with port", "https://portal.example.com:443", "*.portal.example.com:443"},
422
- {"http with port", "http://portal.example.com:8080", "*.portal.example.com:8080"},
423
- {"with path keeps path", "https://portal.example.com/path", "*.portal.example.com/path"},
424
- {"just wildcard", "*.", "*.localhost:4017"},
425
- {"just scheme", "https://", "*.https:"},
426
- {"localhost no port", "localhost", "*.localhost"},
427
- }
428
-
429
- for _, tt := range tests {
430
- t.Run(tt.name, func(t *testing.T) {
431
- result := DefaultAppPattern(tt.input)
432
- assert.Equal(t, tt.expected, result, "DefaultAppPattern(%q)", tt.input)
433
- })
434
- }
435
-}
436
-
437
-func TestDefaultBootstrapFrom(t *testing.T) {
438
- tests := []struct {
439
- name string
440
- input string
441
- expected string
442
- }{
443
- {"empty string", "", "http://localhost:4017"},
444
- {"whitespace", " ", "http://localhost:4017"},
445
- {"localhost with port", "localhost:4017", "http://localhost:4017"},
446
- {"https with domain", "https://portal.example.com", "https://portal.example.com"},
447
- {"http with domain", "http://portal.example.com", "http://portal.example.com"},
448
- {"ws scheme", "ws://example.com", "http://localhost:4017"},
449
- {"wss scheme", "wss://example.com", "http://localhost:4017"},
450
- {"domain only", "example.com", "http://example.com"},
451
- {"with trailing slash", "example.com/", "http://example.com"},
452
- {"with path", "example.com/custom", "http://example.com"},
453
- {"edge case invalid url", "://invalid", "http://localhost:4017"},
454
- }
455
-
456
- for _, tt := range tests {
457
- t.Run(tt.name, func(t *testing.T) {
458
- result := DefaultBootstrapFrom(tt.input)
459
- assert.Equal(t, tt.expected, result, "DefaultBootstrapFrom(%q)", tt.input)
460
- })
461
- }
462
-}
463
-
464
-func TestPortalHostPort(t *testing.T) {
465
- tests := []struct {
466
- name string
467
- input string
468
- expected string
469
- }{
470
- {"https lowercase", "https://Portal.Example.com", "portal.example.com"},
471
- {"with port", "http://portal.example.com:4017", "portal.example.com:4017"},
472
- {"wildcard", "https://*.portal.example.com", "portal.example.com"},
473
- {"bare host", "portal.example.com", "portal.example.com"},
474
- }
475
-
476
- for _, tt := range tests {
477
- t.Run(tt.name, func(t *testing.T) {
478
- assert.Equal(t, tt.expected, PortalHostPort(tt.input))
479
- })
480
- }
481
-}
482
-
483
-func TestPortalBaseHostNoPort(t *testing.T) {
484
- tests := []struct {
485
- name string
486
- input string
487
- expected string
488
- }{
489
- {"with port", "https://portal.example.com:4017", "portal.example.com"},
490
- {"no port", "https://portal.example.com", "portal.example.com"},
491
- {"localhost", "localhost:4017", "localhost"},
492
- }
493
-
494
- for _, tt := range tests {
495
- t.Run(tt.name, func(t *testing.T) {
496
- assert.Equal(t, tt.expected, PortalBaseHostNoPort(tt.input))
497
- })
498
- }
499
-}
500
-
501
-func TestServicePublicURL(t *testing.T) {
502
- tests := []struct {
503
- name string
504
- portalURL string
505
- service string
506
- expected string
507
- shouldFail bool
508
- }{
509
- {"https url", "https://portal.example.com", "demo", "https://demo.portal.example.com", false},
510
- {"http url", "http://portal.example.com:4017", "demo", "http://demo.portal.example.com:4017", false},
511
- {"bare host", "portal.example.com", "demo", "http://demo.portal.example.com", false},
512
- {"empty service", "https://portal.example.com", "", "", true},
513
- {"empty portal", "", "demo", "", true},
514
- }
515
-
516
- for _, tt := range tests {
517
- t.Run(tt.name, func(t *testing.T) {
518
- got := ServicePublicURL(tt.portalURL, tt.service)
519
- if tt.shouldFail {
520
- assert.Equal(t, "", got)
521
- return
522
- }
523
- assert.Equal(t, tt.expected, got)
524
- })
525
- }
526
-}