package tlsservice import ( "crypto/tls" "crypto/x509" "encoding/pem" "errors" "fmt" "log" "net" "net/http" "os" "path/filepath" "regexp" "strconv" "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])?)+$`) var numericHostPattern = regexp.MustCompile(`^[0-9.]+$`) type configuration struct { domain string scheme string port int 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"` Scheme string `json:"scheme"` ConfiguredScheme string `json:"configured_scheme"` Domain string `json:"domain"` ConfiguredDomain string `json:"configured_domain"` Port int `json:"port"` ConfiguredPort int `json:"configured_port"` PublicBase string `json:"public_base"` ConfiguredPublicBase string `json:"configured_public_base"` 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"` CertSource string `json:"cert_source,omitempty"` 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"` } // DataDir returns the active TLS data directory (empty before Start). func DataDir() string { state.RLock() defer state.RUnlock() return state.config.dataDir } func ValidDomain(domain string) bool { return len(domain) <= 253 && domainPattern.MatchString(domain) && !numericHostPattern.MatchString(domain) } // ValidPublicHost allows a literal IPv4 address only for an HTTP gateway. // Certificate issuance and HTTPS remain restricted to DNS names. func ValidPublicHost(scheme, host string) bool { if !ValidScheme(scheme) { return false } if scheme == "https" { return ValidDomain(host) } if ip := net.ParseIP(host); ip != nil { return ip.To4() != nil && !ip.IsUnspecified() && !ip.IsMulticast() } return ValidDomain(host) } func ValidScheme(scheme string) bool { return scheme == "http" || scheme == "https" } // EffectivePort returns the listen/public port. port<=0 means scheme default. func EffectivePort(scheme string, port int) int { if port > 0 { return port } if scheme == "http" { return 80 } return 443 } // PublicBaseURL builds {scheme}://{host}[:port], omitting :80/:443 defaults. func PublicBaseURL(scheme, host string, port int) string { scheme = strings.ToLower(strings.TrimSpace(scheme)) host = strings.ToLower(strings.TrimSpace(host)) if !ValidPublicHost(scheme, host) { return "" } eff := EffectivePort(scheme, port) if (scheme == "http" && eff == 80) || (scheme == "https" && eff == 443) { return scheme + "://" + host } return fmt.Sprintf("%s://%s:%d", scheme, host, eff) } func normalizeScheme(scheme string) string { scheme = strings.ToLower(strings.TrimSpace(scheme)) if scheme == "" { return "https" } return scheme } func readPersisted(dataDir, name string) (string, error) { raw, err := os.ReadFile(filepath.Join(dataDir, name)) if errors.Is(err, os.ErrNotExist) { return "", nil } if err != nil { return "", err } return strings.TrimSpace(string(raw)), nil } func writePersisted(dataDir, name, value string) error { if err := os.MkdirAll(dataDir, 0o700); err != nil { return err } temp, err := os.CreateTemp(dataDir, "."+name+"-*") 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(value + "\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, name)) } func configFromEnvironment(dataDir string) (configuration, error) { mode := os.Getenv("VPN_TLS_MODE") if mode == "" { raw, err := readPersisted(dataDir, "tls-mode") if err != nil { return configuration{}, fmt.Errorf("读取 TLS 模式配置: %w", err) } mode = raw } if mode == "" { mode = "nginx" } 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 == "" { raw, err := readPersisted(dataDir, "tls-domain") if err != nil { return configuration{}, fmt.Errorf("读取 TLS 域名配置: %w", err) } domain = strings.ToLower(raw) } schemeEnv := strings.TrimSpace(os.Getenv("VPN_TLS_SCHEME")) scheme := normalizeScheme(schemeEnv) if schemeEnv == "" { raw, err := readPersisted(dataDir, "tls-scheme") if err != nil { return configuration{}, fmt.Errorf("读取公网协议配置: %w", err) } scheme = normalizeScheme(raw) } if !ValidScheme(scheme) { return configuration{}, fmt.Errorf("VPN_TLS_SCHEME 仅支持 http 或 https") } if domain != "" && !ValidPublicHost(scheme, domain) { return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 必须是有效域名,HTTP 模式也可使用 IPv4 地址") } port := 0 if raw := strings.TrimSpace(os.Getenv("VPN_TLS_PORT")); raw != "" { var err error port, err = strconv.Atoi(raw) if err != nil || port < 1 || port > 65535 { return configuration{}, fmt.Errorf("VPN_TLS_PORT 必须是 1-65535") } } else { raw, err := readPersisted(dataDir, "tls-port") if err != nil { return configuration{}, fmt.Errorf("读取公网端口配置: %w", err) } if raw != "" { port, err = strconv.Atoi(raw) if err != nil || port < 1 || port > 65535 { return configuration{}, fmt.Errorf("tls-port 必须是 1-65535") } } } httpAddr := os.Getenv("VPN_TLS_HTTP_ADDR") if httpAddr == "" { if scheme == "http" { httpAddr = fmt.Sprintf(":%d", EffectivePort(scheme, port)) } else { httpAddr = ":80" } } httpsAddr := os.Getenv("VPN_TLS_HTTPS_ADDR") if httpsAddr == "" { if scheme == "https" { httpsAddr = fmt.Sprintf(":%d", EffectivePort(scheme, port)) } else { httpsAddr = ":443" } } return configuration{domain: domain, scheme: scheme, port: port, mode: mode, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil } func ConfigureDomain(dataDir, domain string) error { return ConfigurePublicEntry(dataDir, "https", domain, 0) } // ConfigurePublicEntry persists scheme/host/port for the public management entry. // port<=0 means scheme default (80 for http, 443 for https). func ConfigurePublicEntry(dataDir, scheme, domain string, port int) error { if os.Getenv("VPN_TLS_DOMAIN") != "" || os.Getenv("VPN_TLS_SCHEME") != "" || os.Getenv("VPN_TLS_PORT") != "" { return fmt.Errorf("当前由 VPN_TLS_* 环境变量管理公网入口,请修改启动配置") } scheme = normalizeScheme(scheme) domain = strings.ToLower(strings.TrimSpace(domain)) if !ValidScheme(scheme) { return fmt.Errorf("协议仅支持 http 或 https") } if !ValidPublicHost(scheme, domain) { return fmt.Errorf("请输入有效域名;HTTP 也支持 IPv4 地址") } if port < 0 || port > 65535 { return fmt.Errorf("端口必须是 0-65535(0 表示协议默认端口)") } if scheme == "https" && EffectivePort(scheme, port) == 80 { return fmt.Errorf("HTTPS 不能使用 HTTP-01 验证端口 80") } portValue := "" if port > 0 { portValue = fmt.Sprintf("%d", port) } values := []struct{ name, value string }{{"tls-domain", domain}, {"tls-scheme", scheme}, {"tls-port", portValue}} old := make([]string, len(values)) for i, entry := range values { var err error old[i], err = readPersisted(dataDir, entry.name) if err != nil { return err } } for i, entry := range values { if err := writePersisted(dataDir, entry.name, entry.value); err != nil { for j := i - 1; j >= 0; j-- { _ = writePersisted(dataDir, values[j].name, old[j]) } return err } } return nil } 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") } return writePersisted(dataDir, "tls-mode", mode) } // AdoptNginxConfiguration updates the in-process public address after the // managed Nginx site has passed validation and reloaded successfully. func AdoptNginxConfiguration(dataDir string) error { config, err := configFromEnvironment(dataDir) if err != nil { return err } if config.mode != "nginx" { return fmt.Errorf("当前入口不是 Nginx 模式") } state.Lock() defer state.Unlock() if state.config.mode != "nginx" || state.config.dataDir != dataDir { return fmt.Errorf("运行模式不支持热更新 Nginx 入口") } state.config = config return nil } // UseManagedNginxEntry keeps the in-process identity issuer aligned with the // live Nginx site if an older version left an unapplied draft on disk. func UseManagedNginxEntry(dataDir, scheme, domain string, port int) error { if !ValidPublicHost(scheme, domain) || port < 1 || port > 65535 { return fmt.Errorf("运行中的 Nginx 公网地址无效") } state.Lock() defer state.Unlock() if state.config.mode != "nginx" || state.config.dataDir != dataDir { return fmt.Errorf("当前运行模式不是 Nginx") } state.config.scheme, state.config.domain, state.config.port = scheme, domain, port return nil } // 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" || normalizeScheme(config.scheme) == "http" { // Plain HTTP public entry is terminated by Nginx (or left unset); Go autocert is HTTPS-only. 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) } publicBase := PublicBaseURL("https", config.domain, config.port) redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { eff := EffectivePort("https", config.port) allowedHost := config.domain allowedWithPort := fmt.Sprintf("%s:%d", config.domain, eff) if r.Host != allowedHost && r.Host != allowedWithPort && 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, publicBase+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() scheme := normalizeScheme(config.scheme) status := CertificateStatus{ Enabled: config.domain != "" && config.mode == "autocert", Domain: config.domain, Scheme: scheme, Port: config.port, 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.ConfiguredScheme = normalizeScheme(desired.scheme) status.ConfiguredPort = desired.port status.ConfiguredPublicBase = PublicBaseURL(status.ConfiguredScheme, desired.domain, desired.port) status.RestartRequired = desired.mode != config.mode || (config.mode != "nginx" && (desired.domain != config.domain || normalizeScheme(desired.scheme) != scheme || desired.port != config.port)) } if config.mode == "nginx" { // Certbot or uploaded PEMs own HTTPS certificates. HTTP needs no leaf. status.Domain = "" domain := config.domain if domain == "" { domain = status.ConfiguredDomain } if domain != "" { if scheme == "http" { status.Domain = domain } else { fullchain, _, source := CertificateFiles(domain) readCertificate(fullchain, domain, &status) if status.Cached || status.Valid { status.CertSource = source } if status.Valid { status.Domain = domain } } } status.PublicBase = PublicBaseURL(scheme, status.Domain, config.port) return status } if !status.Enabled { status.PublicBase = PublicBaseURL(scheme, status.Domain, config.port) 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 { status.PublicBase = PublicBaseURL(scheme, "", config.port) return status } _, data = pem.Decode(data) readCertificateBytes(data, config.domain, &status) if !status.Valid { status.Domain = "" } status.PublicBase = PublicBaseURL(scheme, status.Domain, config.port) 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 } }