feat: configure HTTP/HTTPS public entry and managed certificates

This commit is contained in:
Codex committed 2026-10-26 14:06:41 +08:00
1 parent 35596cda9d
commit 88ea6b7854
21 files changed
+1309 -149

No files matched your search

+238 -77
View File
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -23,6 +24,8 @@ var domainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:
type configuration struct {
domain string
scheme string
port int
mode string
dataDir string
cacheDir string
@@ -36,36 +39,123 @@ var state struct {
}
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"`
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) && net.ParseIP(domain) == nil
}
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 !ValidScheme(scheme) || host == "" || !ValidDomain(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 == "" {
if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-mode")); err == nil {
mode = strings.TrimSpace(string(raw))
} else if !errors.Is(err, os.ErrNotExist) {
raw, err := readPersisted(dataDir, "tls-mode")
if err != nil {
return configuration{}, fmt.Errorf("读取 TLS 模式配置: %w", err)
}
mode = raw
}
if mode == "" {
mode = "autocert"
@@ -75,56 +165,111 @@ func configFromEnvironment(dataDir string) (configuration, error) {
}
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) {
raw, err := readPersisted(dataDir, "tls-domain")
if err != nil {
return configuration{}, fmt.Errorf("读取 TLS 域名配置: %w", err)
}
domain = strings.ToLower(raw)
}
if domain != "" && !ValidDomain(domain) {
return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 不是有效的单个完整域名")
}
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")
}
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 == "" {
httpAddr = ":80"
if scheme == "http" {
httpAddr = fmt.Sprintf(":%d", EffectivePort(scheme, port))
} else {
httpAddr = ":80"
}
}
httpsAddr := os.Getenv("VPN_TLS_HTTPS_ADDR")
if httpsAddr == "" {
httpsAddr = ":443"
if scheme == "https" {
httpsAddr = fmt.Sprintf(":%d", EffectivePort(scheme, port))
} else {
httpsAddr = ":443"
}
}
return configuration{domain: domain, mode: mode, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil
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 {
if os.Getenv("VPN_TLS_DOMAIN") != "" {
return fmt.Errorf("当前由 VPN_TLS_DOMAIN 环境变量管理域名,请修改启动配置")
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 !ValidDomain(domain) {
return fmt.Errorf("请输入单个有效的完整域名")
}
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return err
if port < 0 || port > 65535 {
return fmt.Errorf("端口必须是 0-65535(0 表示协议默认端口)")
}
temp, err := os.CreateTemp(dataDir, ".tls-domain-*")
if err != nil {
return err
if scheme == "https" && EffectivePort(scheme, port) == 80 {
return fmt.Errorf("HTTPS 不能使用 HTTP-01 验证端口 80")
}
defer os.Remove(temp.Name())
defer temp.Close()
if err := temp.Chmod(0o600); err != nil {
return err
portValue := ""
if port > 0 {
portValue = fmt.Sprintf("%d", port)
}
if _, err := temp.WriteString(domain + "\n"); err != nil {
return err
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
}
}
if err := temp.Sync(); 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
}
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(temp.Name(), filepath.Join(dataDir, "tls-domain"))
return nil
}
func ConfigureMode(dataDir, mode string) error {
@@ -134,28 +279,7 @@ func ConfigureMode(dataDir, mode string) error {
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"))
return writePersisted(dataDir, "tls-mode", mode)
}
// Start keeps certificates in the persistent data directory. Issuance occurs
@@ -165,7 +289,8 @@ func Start(handler http.Handler, dataDir string) error {
if err != nil {
return err
}
if config.domain == "" || config.mode == "nginx" {
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()
@@ -188,8 +313,12 @@ func Start(handler http.Handler, dataDir string) error {
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) {
if r.Host != config.domain && r.Host != config.domain+":80" {
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
}
@@ -197,7 +326,7 @@ func Start(handler http.Handler, dataDir string) error {
http.Error(w, "Use HTTPS", http.StatusBadRequest)
return
}
http.Redirect(w, r, "https://"+config.domain+r.URL.RequestURI(), http.StatusPermanentRedirect)
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}
@@ -222,7 +351,17 @@ 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}
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 == "" {
@@ -235,21 +374,38 @@ func Status() CertificateStatus {
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
status.ConfiguredScheme = normalizeScheme(desired.scheme)
status.ConfiguredPort = desired.port
status.ConfiguredPublicBase = PublicBaseURL(status.ConfiguredScheme, desired.domain, desired.port)
status.RestartRequired = desired.domain != config.domain || desired.mode != config.mode ||
normalizeScheme(desired.scheme) != scheme || desired.port != config.port
}
if config.mode == "nginx" {
// Certbot owns this certificate. Read only the public certificate and
// never mistake a configured domain for a listening HTTPS gateway.
// Certbot or uploaded PEMs own HTTPS certificates. HTTP needs no leaf.
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
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 {
@@ -261,10 +417,15 @@ func Status() CertificateStatus {
// 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
}
@@ -88,3 +88,84 @@ func TestNginxModeDoesNotBindGoListeners(t *testing.T) {
t.Fatal("loopback management listener must not be marked public")
}
}
func TestPublicBaseURL(t *testing.T) {
cases := []struct {
scheme, host string
port int
want string
}{
{"https", "vpn.example.com", 0, "https://vpn.example.com"},
{"https", "vpn.example.com", 443, "https://vpn.example.com"},
{"https", "vpn.example.com", 8443, "https://vpn.example.com:8443"},
{"http", "vpn.example.com", 0, "http://vpn.example.com"},
{"http", "vpn.example.com", 80, "http://vpn.example.com"},
{"http", "vpn.example.com", 8080, "http://vpn.example.com:8080"},
{"HTTP", "VPN.Example.Com", 8080, "http://vpn.example.com:8080"},
{"https", "", 443, ""},
{"ftp", "vpn.example.com", 0, ""},
}
for _, tc := range cases {
got := PublicBaseURL(tc.scheme, tc.host, tc.port)
if got != tc.want {
t.Fatalf("PublicBaseURL(%q,%q,%d)=%q want %q", tc.scheme, tc.host, tc.port, got, tc.want)
}
}
}
func TestConfigurePublicEntryHTTPAndCustomPort(t *testing.T) {
t.Setenv("VPN_TLS_DOMAIN", "")
t.Setenv("VPN_TLS_SCHEME", "")
t.Setenv("VPN_TLS_PORT", "")
t.Setenv("VPN_TLS_MODE", "")
dir := t.TempDir()
if err := ConfigureMode(dir, "nginx"); err != nil {
t.Fatal(err)
}
if err := ConfigurePublicEntry(dir, "http", "vpn.example.com", 8080); err != nil {
t.Fatal(err)
}
t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0")
t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0")
if err := Start(http.NotFoundHandler(), dir); err != nil {
t.Fatal(err)
}
status := Status()
if status.Scheme != "http" || status.Domain != "vpn.example.com" || status.Port != 8080 {
t.Fatalf("unexpected status fields: %+v", status)
}
if status.PublicBase != "http://vpn.example.com:8080" {
t.Fatalf("public_base = %q", status.PublicBase)
}
if status.Valid {
t.Fatal("http entry must not require certificate Valid")
}
if status.ConfiguredPublicBase != "http://vpn.example.com:8080" {
t.Fatalf("configured_public_base = %q", status.ConfiguredPublicBase)
}
}
func TestConfigurePublicEntryHTTPSOmitsDefaultPort(t *testing.T) {
t.Setenv("VPN_TLS_DOMAIN", "")
t.Setenv("VPN_TLS_SCHEME", "")
t.Setenv("VPN_TLS_PORT", "")
t.Setenv("VPN_TLS_MODE", "nginx")
dir := t.TempDir()
if err := ConfigurePublicEntry(dir, "https", "vpn.example.com", 443); err != nil {
t.Fatal(err)
}
t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0")
t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0")
if err := Start(http.NotFoundHandler(), dir); err != nil {
t.Fatal(err)
}
// Without a leaf cert, https nginx Domain stays empty; configured base still omits :443.
status := Status()
if status.ConfiguredPublicBase != "https://vpn.example.com" {
t.Fatalf("configured_public_base = %q", status.ConfiguredPublicBase)
}
if status.ConfiguredPort != 443 && status.ConfiguredPort != 0 {
// persisted 443 is fine; EffectivePort still omits in PublicBaseURL
t.Logf("configured port = %d", status.ConfiguredPort)
}
}
+203
View File
@@ -0,0 +1,203 @@
package tlsservice
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
CertSourceUpload = "upload"
CertSourceHTTP01 = "http01"
certSourceFile = "tls-cert-source"
)
var uploadMu sync.Mutex
func uploadedCertDir(dataDir, domain string) string {
return filepath.Join(dataDir, "tls", "certs", strings.ToLower(strings.TrimSpace(domain)))
}
// UploadedCertificatePaths returns on-disk paths for an uploaded material pair.
func UploadedCertificatePaths(dataDir, domain string) (fullchain, privkey string) {
dir := uploadedCertDir(dataDir, domain)
return filepath.Join(dir, "current", "fullchain.pem"), filepath.Join(dir, "current", "privkey.pem")
}
func LetsEncryptPaths(domain string) (fullchain, privkey string) {
domain = strings.ToLower(strings.TrimSpace(domain))
base := filepath.Join("/etc/letsencrypt/live", domain)
return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem")
}
func readCertSource(dataDir string) string {
raw, err := readPersisted(dataDir, certSourceFile)
if err != nil || raw == "" {
return ""
}
raw = strings.ToLower(strings.TrimSpace(raw))
if raw == CertSourceUpload || raw == CertSourceHTTP01 {
return raw
}
return ""
}
func MarkCertSource(dataDir, source string) error {
source = strings.ToLower(strings.TrimSpace(source))
if source != CertSourceUpload && source != CertSourceHTTP01 {
return fmt.Errorf("证书来源仅支持 upload 或 http01")
}
return writePersisted(dataDir, certSourceFile, source)
}
// CertificateFiles picks nginx ssl_certificate paths for domain.
// Upload material in the data dir wins when marked upload or when files exist
// and http01 has not been explicitly marked.
func CertificateFiles(domain string) (fullchain, privkey, source string) {
domain = strings.ToLower(strings.TrimSpace(domain))
state.RLock()
dataDir := state.config.dataDir
state.RUnlock()
if dataDir == "" || domain == "" {
fc, pk := LetsEncryptPaths(domain)
return fc, pk, CertSourceHTTP01
}
marked := readCertSource(dataDir)
upFull, upKey := UploadedCertificatePaths(dataDir, domain)
_, errFull := os.Stat(upFull)
_, errKey := os.Stat(upKey)
uploadPresent := errFull == nil && errKey == nil
leFull, leKey := LetsEncryptPaths(domain)
switch {
case marked == CertSourceUpload && uploadPresent:
return upFull, upKey, CertSourceUpload
case marked == CertSourceHTTP01:
return leFull, leKey, CertSourceHTTP01
case uploadPresent:
return upFull, upKey, CertSourceUpload
default:
return leFull, leKey, CertSourceHTTP01
}
}
func leafCertificateFromPEM(certPEM []byte) (*x509.Certificate, error) {
rest := certPEM
for len(rest) > 0 {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("证书 PEM 无法解析")
}
return cert, nil
}
return nil, fmt.Errorf("未找到 CERTIFICATE PEM 块")
}
// InstallUploadedCertificate validates that certificate_pem and private_key_pem
// form a matching pair for domain, writes them under the data directory, and
// marks cert_source=upload.
func InstallUploadedCertificate(dataDir, domain, certificatePEM, privateKeyPEM string) error {
return InstallUploadedCertificateWithApply(dataDir, domain, certificatePEM, privateKeyPEM, nil)
}
// InstallUploadedCertificateWithApply switches the certificate pair atomically
// and restores the previous source and pair when the gateway reload fails.
func InstallUploadedCertificateWithApply(dataDir, domain, certificatePEM, privateKeyPEM string, apply func() error) error {
uploadMu.Lock()
defer uploadMu.Unlock()
domain = strings.ToLower(strings.TrimSpace(domain))
if !ValidDomain(domain) {
return fmt.Errorf("请输入有效的单个完整域名")
}
certPEM := []byte(strings.TrimSpace(certificatePEM) + "\n")
keyPEM := []byte(strings.TrimSpace(privateKeyPEM) + "\n")
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
return fmt.Errorf("证书与私钥不匹配或 PEM 无效")
}
leaf, err := leafCertificateFromPEM(certPEM)
if err != nil {
return err
}
if err := leaf.VerifyHostname(domain); err != nil {
return fmt.Errorf("证书与域名 %s 不匹配", domain)
}
now := time.Now()
if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) {
return fmt.Errorf("证书不在有效期内")
}
dir := uploadedCertDir(dataDir, domain)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
version, err := os.MkdirTemp(dir, ".certificate-*")
if err != nil {
return err
}
defer func() {
if version != "" {
_ = os.RemoveAll(version)
}
}()
if err := os.WriteFile(filepath.Join(version, "fullchain.pem"), certPEM, 0o600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(version, "privkey.pem"), keyPEM, 0o600); err != nil {
return err
}
current := filepath.Join(dir, "current")
previous, linkErr := os.Readlink(current)
if linkErr != nil && !os.IsNotExist(linkErr) {
return fmt.Errorf("当前证书路径不是托管链接: %w", linkErr)
}
oldSource, err := readPersisted(dataDir, certSourceFile)
if err != nil {
return err
}
link := filepath.Join(dir, ".next-certificate")
if err := os.Symlink(filepath.Base(version), link); err != nil {
return err
}
defer os.Remove(link)
if err := os.Rename(link, current); err != nil {
return err
}
rollback := func() {
if linkErr == nil {
_ = os.Symlink(previous, link)
_ = os.Rename(link, current)
} else {
_ = os.Remove(current)
}
if oldSource == "" {
_ = os.Remove(filepath.Join(dataDir, certSourceFile))
} else {
_ = writePersisted(dataDir, certSourceFile, oldSource)
}
}
if err := MarkCertSource(dataDir, CertSourceUpload); err != nil {
rollback()
return err
}
if apply != nil {
if err := apply(); err != nil {
rollback()
return fmt.Errorf("刷新公网入口失败: %w", err)
}
}
// The version must remain available through the current symlink.
version = ""
return nil
}
+155
View File
@@ -0,0 +1,155 @@
package tlsservice
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func testPEMPair(t *testing.T, dnsNames []string) (certPEM, keyPEM string, otherKeyPEM string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: dnsNames[0], Organization: []string{"opsx-vpn-test"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: dnsNames,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
otherDER, err := x509.MarshalECPrivateKey(other)
if err != nil {
t.Fatal(err)
}
return string(certPEMBytes),
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})),
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: otherDER}))
}
func TestInstallUploadedCertificateSuccess(t *testing.T) {
t.Setenv("VPN_TLS_DOMAIN", "")
t.Setenv("VPN_TLS_SCHEME", "")
t.Setenv("VPN_TLS_PORT", "")
t.Setenv("VPN_TLS_MODE", "")
dir := t.TempDir()
certPEM, keyPEM, _ := testPEMPair(t, []string{"vpn.example.com"})
if err := ConfigureMode(dir, "nginx"); err != nil {
t.Fatal(err)
}
if err := ConfigurePublicEntry(dir, "https", "vpn.example.com", 0); err != nil {
t.Fatal(err)
}
if err := InstallUploadedCertificate(dir, "vpn.example.com", certPEM, keyPEM); err != nil {
t.Fatal(err)
}
fullchain, privkey := UploadedCertificatePaths(dir, "vpn.example.com")
if st, err := os.Stat(fullchain); err != nil || st.Mode().Perm() != 0o600 {
t.Fatalf("fullchain perms: %v %v", st, err)
}
if st, err := os.Stat(privkey); err != nil || st.Mode().Perm() != 0o600 {
t.Fatalf("privkey perms: %v %v", st, err)
}
if got := readCertSource(dir); got != CertSourceUpload {
t.Fatalf("cert_source=%q", got)
}
t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0")
t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0")
if err := Start(http.NotFoundHandler(), dir); err != nil {
t.Fatal(err)
}
status := Status()
if !status.Valid || status.CertSource != CertSourceUpload || status.Domain != "vpn.example.com" {
t.Fatalf("status after upload: %+v", status)
}
if status.Issuer == "" || status.NotAfter.IsZero() {
t.Fatalf("missing issuer/expiry: %+v", status)
}
if status.PublicBase != "https://vpn.example.com" {
t.Fatalf("public_base=%q", status.PublicBase)
}
fc, pk, source := CertificateFiles("vpn.example.com")
if source != CertSourceUpload || fc != fullchain || pk != privkey {
t.Fatalf("CertificateFiles = %q %q %q", fc, pk, source)
}
}
func TestInstallUploadedCertificateKeyMismatch(t *testing.T) {
dir := t.TempDir()
certPEM, _, otherKey := testPEMPair(t, []string{"vpn.example.com"})
err := InstallUploadedCertificate(dir, "vpn.example.com", certPEM, otherKey)
if err == nil {
t.Fatal("expected mismatch error")
}
if _, statErr := os.Stat(filepath.Join(dir, "tls", "certs", "vpn.example.com", "fullchain.pem")); !os.IsNotExist(statErr) {
t.Fatal("mismatch must not write certificate files")
}
if readCertSource(dir) != "" {
t.Fatal("mismatch must not mark cert_source")
}
}
func TestInstallUploadedCertificateDomainMismatch(t *testing.T) {
dir := t.TempDir()
certPEM, keyPEM, _ := testPEMPair(t, []string{"other.example.com"})
if err := InstallUploadedCertificate(dir, "vpn.example.com", certPEM, keyPEM); err == nil {
t.Fatal("expected hostname mismatch")
}
}
func TestUploadRollbackPreservesPreviousCertificate(t *testing.T) {
dir := t.TempDir()
firstCert, firstKey, _ := testPEMPair(t, []string{"vpn.example.com"})
secondCert, secondKey, _ := testPEMPair(t, []string{"vpn.example.com"})
if err := InstallUploadedCertificate(dir, "vpn.example.com", firstCert, firstKey); err != nil {
t.Fatal(err)
}
fullchain, privkey := UploadedCertificatePaths(dir, "vpn.example.com")
if err := InstallUploadedCertificateWithApply(dir, "vpn.example.com", secondCert, secondKey, func() error {
return errors.New("nginx reload failed")
}); err == nil {
t.Fatal("expected reload error")
}
gotCert, err := os.ReadFile(fullchain)
if err != nil || strings.TrimSpace(string(gotCert)) != strings.TrimSpace(firstCert) {
t.Fatalf("old certificate not restored: %v", err)
}
gotKey, err := os.ReadFile(privkey)
if err != nil || strings.TrimSpace(string(gotKey)) != strings.TrimSpace(firstKey) {
t.Fatalf("old key not restored: %v", err)
}
if readCertSource(dir) != CertSourceUpload {
t.Fatal("certificate source not restored")
}
}