feat(tls): manage ACME domain and add desktop client settings

This commit is contained in:
opsx committed 2026-10-21 10:25:55 +08:00
1 parent 003f755b37
commit 00f6c876cf
85 files changed
+7276 -1

No files matched your search

+217
View File
@@ -0,0 +1,217 @@
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
}