Files
opsx-vpn/backend/internal/tlsservice/tlsservice.go
T

218 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tlsservice
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/crypto/acme/autocert"
)
var domainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
type configuration struct {
domain string
dataDir string
cacheDir string
httpAddr string
httpsAddr string
}
var state struct {
sync.RWMutex
config configuration
}
type CertificateStatus struct {
Enabled bool `json:"enabled"`
Domain string `json:"domain"`
ConfiguredDomain string `json:"configured_domain"`
RestartRequired bool `json:"restart_required"`
DNS []string `json:"dns"`
HTTPAddr string `json:"http_address"`
HTTPSAddr string `json:"https_address"`
Cached bool `json:"certificate_cached"`
Valid bool `json:"certificate_valid"`
Issuer string `json:"issuer,omitempty"`
NotBefore time.Time `json:"not_before,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
Renewal string `json:"renewal_mode"`
}
func ValidDomain(domain string) bool {
return len(domain) <= 253 && domainPattern.MatchString(domain) && net.ParseIP(domain) == nil
}
func configFromEnvironment(dataDir string) (configuration, error) {
domain := strings.ToLower(strings.TrimSpace(os.Getenv("VPN_TLS_DOMAIN")))
if domain == "" {
if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-domain")); err == nil {
domain = strings.ToLower(strings.TrimSpace(string(raw)))
} else if !errors.Is(err, os.ErrNotExist) {
return configuration{}, fmt.Errorf("读取 TLS 域名配置: %w", err)
}
}
if domain != "" && !ValidDomain(domain) {
return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 不是有效的单个完整域名")
}
httpAddr := os.Getenv("VPN_TLS_HTTP_ADDR")
if httpAddr == "" {
httpAddr = ":80"
}
httpsAddr := os.Getenv("VPN_TLS_HTTPS_ADDR")
if httpsAddr == "" {
httpsAddr = ":443"
}
return configuration{domain: domain, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil
}
func ConfigureDomain(dataDir, domain string) error {
if os.Getenv("VPN_TLS_DOMAIN") != "" {
return fmt.Errorf("当前由 VPN_TLS_DOMAIN 环境变量管理域名,请修改启动配置")
}
domain = strings.ToLower(strings.TrimSpace(domain))
if !ValidDomain(domain) {
return fmt.Errorf("请输入单个有效的完整域名")
}
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return err
}
temp, err := os.CreateTemp(dataDir, ".tls-domain-*")
if err != nil {
return err
}
defer os.Remove(temp.Name())
defer temp.Close()
if err := temp.Chmod(0o600); err != nil {
return err
}
if _, err := temp.WriteString(domain + "\n"); err != nil {
return err
}
if err := temp.Sync(); err != nil {
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(temp.Name(), filepath.Join(dataDir, "tls-domain"))
}
// Start keeps certificates in the persistent data directory. Issuance occurs
// on the first TLS handshake, and autocert handles renewal.
func Start(handler http.Handler, dataDir string) error {
config, err := configFromEnvironment(dataDir)
if err != nil {
return err
}
if config.domain == "" {
state.Lock()
state.config = config
state.Unlock()
return nil
}
if err := os.MkdirAll(config.cacheDir, 0o700); err != nil {
return fmt.Errorf("建立 ACME 缓存目录: %w", err)
}
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(config.domain),
Cache: autocert.DirCache(config.cacheDir),
}
httpListener, err := net.Listen("tcp", config.httpAddr)
if err != nil {
return fmt.Errorf("监听 HTTP-01 端口 %s: %w", config.httpAddr, err)
}
httpsListener, err := net.Listen("tcp", config.httpsAddr)
if err != nil {
httpListener.Close()
return fmt.Errorf("监听 HTTPS 端口 %s: %w", config.httpsAddr, err)
}
redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != config.domain && r.Host != config.domain+":80" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Use HTTPS", http.StatusBadRequest)
return
}
http.Redirect(w, r, "https://"+config.domain+r.URL.RequestURI(), http.StatusPermanentRedirect)
})
httpServer := &http.Server{Handler: manager.HTTPHandler(redirect), ReadHeaderTimeout: 10 * time.Second}
httpsServer := &http.Server{Handler: handler, TLSConfig: manager.TLSConfig(), ReadHeaderTimeout: 10 * time.Second}
state.Lock()
state.config = config
state.Unlock()
go func() {
if err := httpServer.Serve(httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("HTTP-01 监听失败: %v", err)
}
}()
go func() {
if err := httpsServer.Serve(tls.NewListener(httpsListener, httpsServer.TLSConfig)); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("HTTPS 监听失败: %v", err)
}
}()
log.Printf("HTTPS/ACME 已启用:%s,HTTP %s,HTTPS %s", config.domain, config.httpAddr, config.httpsAddr)
return nil
}
func Status() CertificateStatus {
state.RLock()
config := state.config
state.RUnlock()
status := CertificateStatus{Enabled: config.domain != "", Domain: config.domain, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: "autocert"}
if desired, err := configFromEnvironment(config.dataDir); err == nil {
status.ConfiguredDomain = desired.domain
status.RestartRequired = desired.domain != config.domain
}
if !status.Enabled {
return status
}
if ips, err := net.LookupIP(config.domain); err == nil {
for _, ip := range ips {
status.DNS = append(status.DNS, ip.String())
}
}
// autocert caches a private key PEM followed by a certificate chain.
// Parse only the public leaf metadata; never return private key material.
data, err := os.ReadFile(filepath.Join(config.cacheDir, config.domain))
if err != nil {
return status
}
_, data = pem.Decode(data)
for len(data) > 0 {
block, rest := pem.Decode(data)
if block == nil {
break
}
data = rest
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil || cert.VerifyHostname(config.domain) != nil {
break
}
status.Cached = true
status.Issuer = cert.Issuer.CommonName
status.NotBefore = cert.NotBefore
status.NotAfter = cert.NotAfter
status.Valid = time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter)
break
}
return status
}