diff --git a/pkg/httpsrv/README.md b/pkg/httpsrv/README.md index 1d063319..24c705d4 100644 --- a/pkg/httpsrv/README.md +++ b/pkg/httpsrv/README.md @@ -142,6 +142,10 @@ func main() { "your-email@example.com", // Your email for Let's Encrypt notifications // Optional: Enable HTTP -> HTTPS automatic redirection (listens on :80 by default) //httpsrv.WithTLSEncryptEnableRedirect(), + // Optional: Redirect to a non-standard HTTPS port + //httpsrv.WithTLSEncryptRedirectHTTPSPort(8443), + // Optional: Add more domains to the certificate whitelist + //httpsrv.WithTLSEncryptDomains("www.your-domain.com"), // Optional: Custom certificate cache directory //httpsrv.WithTLSEncryptCacheDir("certs/encrypt"), ) diff --git a/pkg/httpsrv/readme-cn.md b/pkg/httpsrv/readme-cn.md index cbea69e3..77524718 100644 --- a/pkg/httpsrv/readme-cn.md +++ b/pkg/httpsrv/readme-cn.md @@ -140,6 +140,10 @@ func main() { "your-email@example.com", // 你的邮箱,用于接收 Let's Encrypt 通知 // 可选:开启 HTTP -> HTTPS 自动重定向 (默认监听 :80) //httpsrv.WithTLSEncryptEnableRedirect(), + // 可选:重定向到非标准 HTTPS 端口 + //httpsrv.WithTLSEncryptRedirectHTTPSPort(8443), + // 可选:为证书白名单添加更多域名 + //httpsrv.WithTLSEncryptDomains("www.your-domain.com"), // 可选:自定义证书缓存目录 //httpsrv.WithTLSEncryptCacheDir("certs/encrypt"), ) diff --git a/pkg/httpsrv/tls_auto_encrypt.go b/pkg/httpsrv/tls_auto_encrypt.go index 82923f87..d0a06132 100644 --- a/pkg/httpsrv/tls_auto_encrypt.go +++ b/pkg/httpsrv/tls_auto_encrypt.go @@ -4,7 +4,10 @@ import ( "context" "errors" "fmt" + "net" "net/http" + "strconv" + "strings" "time" "golang.org/x/crypto/acme/autocert" @@ -14,9 +17,11 @@ import ( type TLSEncryptOption func(*tlsEncryptOptions) type tlsEncryptOptions struct { - cacheDir string - httpAddr string - enableRedirect bool + cacheDir string + domains []string + httpAddr string + enableRedirect bool + redirectHTTPSPort int } func (o *tlsEncryptOptions) apply(opts ...TLSEncryptOption) { @@ -27,9 +32,10 @@ func (o *tlsEncryptOptions) apply(opts ...TLSEncryptOption) { func defaultTLSEncryptOptions() *tlsEncryptOptions { return &tlsEncryptOptions{ - cacheDir: "configs/encrypt_certs", - enableRedirect: false, - httpAddr: ":80", + cacheDir: "configs/encrypt_certs", + enableRedirect: false, + httpAddr: ":80", + redirectHTTPSPort: 443, } } @@ -40,6 +46,13 @@ func WithTLSEncryptCacheDir(cacheDir string) TLSEncryptOption { } } +// WithTLSEncryptDomains adds domains to the Let's Encrypt host whitelist. +func WithTLSEncryptDomains(domains ...string) TLSEncryptOption { + return func(o *tlsEncryptOptions) { + o.domains = append(o.domains, domains...) + } +} + // WithTLSEncryptEnableRedirect enables the HTTP-to-HTTPS redirect service. // By default, it listens on ":80". // An optional httpAddr can be provided to specify a different address. @@ -52,16 +65,27 @@ func WithTLSEncryptEnableRedirect(httpAddr ...string) TLSEncryptOption { } } +// WithTLSEncryptRedirectHTTPSPort sets the HTTPS port used in HTTP-to-HTTPS redirects. +// When httpsPort is 443 or less than 1, redirects use the default HTTPS port without +// appending a port to the target host. +func WithTLSEncryptRedirectHTTPSPort(httpsPort int) TLSEncryptOption { + return func(o *tlsEncryptOptions) { + o.redirectHTTPSPort = httpsPort + } +} + // ------------------------------------------------------------------------------------------ var _ TLSer = (*TLSAutoEncryptConfig)(nil) type TLSAutoEncryptConfig struct { - domain string // The domain to request a certificate for in production mode. - email string // Used for Let's Encrypt account registration and important notices. - cacheDir string // Directory to store Let's Encrypt certificates. - httpAddr string // Listen address for the HTTP redirect service (defaults to :80). - enableRedirect bool // Enable HTTP-to-HTTPS redirect service (default: false). + domain string // The primary domain to request a certificate for in production mode. + domains []string // Domain whitelist for Let's Encrypt certificates. + email string // Used for Let's Encrypt account registration and important notices. + cacheDir string // Directory to store Let's Encrypt certificates. + httpAddr string // Listen address for the HTTP redirect service (defaults to :80). + enableRedirect bool // Enable HTTP-to-HTTPS redirect service (default: false). + redirectHTTPSPort int // HTTPS port used in redirect targets (defaults to 443). m *autocert.Manager // Manages certificates automatically. redirectServer *http.Server // The HTTP redirect server. @@ -72,18 +96,22 @@ func NewTLSEAutoEncryptConfig(domain string, email string, opts ...TLSEncryptOpt o.apply(opts...) return &TLSAutoEncryptConfig{ - domain: domain, - email: email, - cacheDir: o.cacheDir, - httpAddr: o.httpAddr, - enableRedirect: o.enableRedirect, + domain: strings.TrimSpace(domain), + domains: normalizeDomains(append([]string{domain}, o.domains...)), + email: email, + cacheDir: o.cacheDir, + httpAddr: o.httpAddr, + enableRedirect: o.enableRedirect, + redirectHTTPSPort: o.redirectHTTPSPort, } } func (c *TLSAutoEncryptConfig) Validate() error { - if c.domain == "" { + c.domains = normalizeDomains(append([]string{c.domain}, c.domains...)) + if len(c.domains) == 0 { return errors.New("domain must be specified in encrypt mode") } + c.domain = c.domains[0] if c.email == "" { return errors.New("email must be specified in encrypt mode") } @@ -93,6 +121,9 @@ func (c *TLSAutoEncryptConfig) Validate() error { if c.httpAddr == "" { c.httpAddr = ":80" } + if c.redirectHTTPSPort < 1 { + c.redirectHTTPSPort = 443 + } return nil } @@ -100,7 +131,7 @@ func (c *TLSAutoEncryptConfig) Run(server *http.Server) error { m := &autocert.Manager{ Cache: autocert.DirCache(c.cacheDir), Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(c.domain), + HostPolicy: autocert.HostWhitelist(c.domains...), Email: c.email, } c.m = m @@ -128,7 +159,7 @@ func (c *TLSAutoEncryptConfig) Run(server *http.Server) error { func (c *TLSAutoEncryptConfig) redirectHTTP() error { server := &http.Server{ Addr: c.httpAddr, - Handler: c.m.HTTPHandler(nil), // Handles ACME challenges and redirection. + Handler: c.m.HTTPHandler(c.redirectHandler()), // Handles ACME challenges and redirection. } c.redirectServer = server @@ -138,6 +169,30 @@ func (c *TLSAutoEncryptConfig) redirectHTTP() error { return nil } +func (c *TLSAutoEncryptConfig) redirectHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "Use HTTPS", http.StatusBadRequest) + return + } + + host := strings.TrimSpace(r.Host) + if parsedHost, _, err := net.SplitHostPort(host); err == nil && parsedHost != "" { + host = parsedHost + } + if host == "" && r.URL != nil { + host = r.URL.Host + } + + targetHost := host + if host != "" && c.redirectHTTPSPort > 0 && c.redirectHTTPSPort != 443 { + targetHost = net.JoinHostPort(host, strconv.Itoa(c.redirectHTTPSPort)) + } + + http.Redirect(w, r, "https://"+targetHost+r.URL.RequestURI(), http.StatusFound) + }) +} + func (c *TLSAutoEncryptConfig) shutDownRedirectHTTP() error { if c.redirectServer == nil { return nil @@ -146,3 +201,20 @@ func (c *TLSAutoEncryptConfig) shutDownRedirectHTTP() error { defer cancel() return c.redirectServer.Shutdown(ctx) } + +func normalizeDomains(domains []string) []string { + seen := map[string]struct{}{} + filtered := make([]string, 0, len(domains)) + for _, domain := range domains { + domain = strings.TrimSpace(domain) + if domain == "" { + continue + } + if _, ok := seen[domain]; ok { + continue + } + seen[domain] = struct{}{} + filtered = append(filtered, domain) + } + return filtered +} diff --git a/pkg/httpsrv/tls_auto_encrypt_test.go b/pkg/httpsrv/tls_auto_encrypt_test.go index 4538e857..8b2cdff7 100644 --- a/pkg/httpsrv/tls_auto_encrypt_test.go +++ b/pkg/httpsrv/tls_auto_encrypt_test.go @@ -3,6 +3,7 @@ package httpsrv import ( "context" "net/http" + "net/http/httptest" "testing" "time" ) @@ -50,6 +51,13 @@ func TestTLSAutoEncryptConfig_Validate(t *testing.T) { opts: []TLSEncryptOption{WithTLSEncryptCacheDir("/tmp/certs")}, wantError: false, }, + { + name: "with extra domains", + domain: "example.com", + email: "admin@example.com", + opts: []TLSEncryptOption{WithTLSEncryptDomains("www.example.com", "api.example.com")}, + wantError: false, + }, } for _, tt := range tests { @@ -81,39 +89,52 @@ func TestTLSAutoEncryptConfig_DefaultValues(t *testing.T) { func TestTLSEncryptOptions(t *testing.T) { tests := []struct { - name string - opts []TLSEncryptOption - expectedDir string - expectedAddr string - expectedRedirect bool + name string + opts []TLSEncryptOption + expectedDir string + expectedAddr string + expectedRedirect bool + expectedRedirectHTTPSPort int }{ { - name: "default options", - opts: nil, - expectedDir: "configs/encrypt_certs", - expectedAddr: ":80", - expectedRedirect: false, + name: "default options", + opts: nil, + expectedDir: "configs/encrypt_certs", + expectedAddr: ":80", + expectedRedirect: false, + expectedRedirectHTTPSPort: 443, + }, + { + name: "custom cache directory", + opts: []TLSEncryptOption{WithTLSEncryptCacheDir("/custom/dir")}, + expectedDir: "/custom/dir", + expectedAddr: ":80", + expectedRedirect: false, + expectedRedirectHTTPSPort: 443, }, { - name: "custom cache directory", - opts: []TLSEncryptOption{WithTLSEncryptCacheDir("/custom/dir")}, - expectedDir: "/custom/dir", - expectedAddr: ":80", - expectedRedirect: false, + name: "enable redirect with default address", + opts: []TLSEncryptOption{WithTLSEncryptEnableRedirect()}, + expectedDir: "configs/encrypt_certs", + expectedAddr: ":80", + expectedRedirect: true, + expectedRedirectHTTPSPort: 443, }, { - name: "enable redirect with default address", - opts: []TLSEncryptOption{WithTLSEncryptEnableRedirect()}, - expectedDir: "configs/encrypt_certs", - expectedAddr: ":80", - expectedRedirect: true, + name: "enable redirect with custom address", + opts: []TLSEncryptOption{WithTLSEncryptEnableRedirect(":8080")}, + expectedDir: "configs/encrypt_certs", + expectedAddr: ":8080", + expectedRedirect: true, + expectedRedirectHTTPSPort: 443, }, { - name: "enable redirect with custom address", - opts: []TLSEncryptOption{WithTLSEncryptEnableRedirect(":8080")}, - expectedDir: "configs/encrypt_certs", - expectedAddr: ":8080", - expectedRedirect: true, + name: "enable redirect with custom https port", + opts: []TLSEncryptOption{WithTLSEncryptEnableRedirect(":8080"), WithTLSEncryptRedirectHTTPSPort(8443)}, + expectedDir: "configs/encrypt_certs", + expectedAddr: ":8080", + expectedRedirect: true, + expectedRedirectHTTPSPort: 8443, }, } @@ -131,6 +152,64 @@ func TestTLSEncryptOptions(t *testing.T) { if o.enableRedirect != tt.expectedRedirect { t.Errorf("enableRedirect = %v, want %v", o.enableRedirect, tt.expectedRedirect) } + if o.redirectHTTPSPort != tt.expectedRedirectHTTPSPort { + t.Errorf("redirectHTTPSPort = %v, want %v", o.redirectHTTPSPort, tt.expectedRedirectHTTPSPort) + } + }) + } +} + +func TestTLSAutoEncryptConfig_RedirectHandler(t *testing.T) { + tests := []struct { + name string + httpsPort int + host string + wantLocation string + wantStatus int + requestMethod string + }{ + { + name: "default https port", + httpsPort: 443, + host: "example.com:8080", + wantLocation: "https://example.com/login?next=%2F", + wantStatus: http.StatusFound, + requestMethod: http.MethodGet, + }, + { + name: "custom https port", + httpsPort: 8443, + host: "example.com:8080", + wantLocation: "https://example.com:8443/login?next=%2F", + wantStatus: http.StatusFound, + requestMethod: http.MethodGet, + }, + { + name: "non get method", + httpsPort: 8443, + host: "example.com:8080", + wantStatus: http.StatusBadRequest, + requestMethod: http.MethodPost, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := NewTLSEAutoEncryptConfig("example.com", "admin@example.com", + WithTLSEncryptRedirectHTTPSPort(tt.httpsPort)) + req := httptest.NewRequest(tt.requestMethod, "http://"+tt.host+"/login?next=%2F", nil) + recorder := httptest.NewRecorder() + + config.redirectHandler().ServeHTTP(recorder, req) + + if recorder.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", recorder.Code, tt.wantStatus) + } + if tt.wantLocation != "" { + if got := recorder.Header().Get("Location"); got != tt.wantLocation { + t.Fatalf("Location = %q, want %q", got, tt.wantLocation) + } + } }) } }