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 - "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
42 - "Double &#34;quotes&#34; and &lt;tags&gt;",
43 - "https://example.com/img?q=1&amp;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 -}