Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg/httpsrv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Expand Down
4 changes: 4 additions & 0 deletions pkg/httpsrv/readme-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Expand Down
110 changes: 91 additions & 19 deletions pkg/httpsrv/tls_auto_encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"

"golang.org/x/crypto/acme/autocert"
Expand All @@ -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) {
Expand All @@ -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,
}
}

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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")
}
Expand All @@ -93,14 +121,17 @@ func (c *TLSAutoEncryptConfig) Validate() error {
if c.httpAddr == "" {
c.httpAddr = ":80"
}
if c.redirectHTTPSPort < 1 {
c.redirectHTTPSPort = 443
}
return nil
}

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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Comment on lines +179 to +192
Comment on lines +183 to +192
})
}

func (c *TLSAutoEncryptConfig) shutDownRedirectHTTP() error {
if c.redirectServer == nil {
return nil
Expand All @@ -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
}
Comment on lines +205 to +220
129 changes: 104 additions & 25 deletions pkg/httpsrv/tls_auto_encrypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package httpsrv
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
}

Expand All @@ -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)
}
}
})
}
}
Expand Down
Loading