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

511 lines
16 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"
"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 = "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 == "" {
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
}
}