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 mode string dataDir string cacheDir string httpAddr string httpsAddr string } var state struct { sync.RWMutex config configuration } type CertificateStatus struct { Enabled bool `json:"enabled"` Mode string `json:"mode"` ConfiguredMode string `json:"configured_mode"` 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) { mode := os.Getenv("VPN_TLS_MODE") if mode == "" { if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-mode")); err == nil { mode = strings.TrimSpace(string(raw)) } else if !errors.Is(err, os.ErrNotExist) { return configuration{}, fmt.Errorf("读取 TLS 模式配置: %w", err) } } if mode == "" { mode = "autocert" } if mode != "autocert" && mode != "nginx" { return configuration{}, fmt.Errorf("VPN_TLS_MODE 仅支持 autocert 或 nginx") } 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, mode: mode, 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")) } func ConfigureMode(dataDir, mode string) error { if os.Getenv("VPN_TLS_MODE") != "" { return fmt.Errorf("当前由 VPN_TLS_MODE 环境变量管理入口模式,请修改启动配置") } if mode != "autocert" && mode != "nginx" { return fmt.Errorf("入口模式仅支持 autocert 或 nginx") } if err := os.MkdirAll(dataDir, 0o700); err != nil { return err } temp, err := os.CreateTemp(dataDir, ".tls-mode-*") 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(mode + "\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-mode")) } // 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 == "" || config.mode == "nginx" { 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 != "" && config.mode == "autocert", Domain: config.domain, Mode: config.mode, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: config.mode} if desired, err := configFromEnvironment(config.dataDir); err == nil { status.ConfiguredDomain = desired.domain status.ConfiguredMode = desired.mode status.RestartRequired = desired.domain != config.domain || desired.mode != config.mode } if config.mode == "nginx" { // Certbot owns this certificate. Read only the public certificate and // never mistake a configured domain for a listening HTTPS gateway. status.Domain = "" if config.domain != "" { readCertificate(filepath.Join("/etc/letsencrypt/live", config.domain, "fullchain.pem"), config.domain, &status) if status.Valid { status.Domain = config.domain } } return status } 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) readCertificateBytes(data, config.domain, &status) return status } func readCertificate(path, domain string, status *CertificateStatus) { data, err := os.ReadFile(path) if err == nil { readCertificateBytes(data, domain, status) } } func readCertificateBytes(data []byte, domain string, status *CertificateStatus) { 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(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 } }