511 lines
16 KiB
Go
511 lines
16 KiB
Go
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
|
||
}
|
||
}
|