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 }