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

300 lines
9.1 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
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"`
DirectPortPublic bool `json:"management_direct_public"`
}
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 config.mode == "nginx" {
listenAddress := os.Getenv("VPN_LISTEN_ADDR")
if listenAddress == "" {
listenAddress = ":18080"
}
if host, _, err := net.SplitHostPort(listenAddress); err == nil {
status.DirectPortPublic = host == "" || host == "0.0.0.0" || host == "::"
}
}
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
}
}