204 lines
6.0 KiB
Go
204 lines
6.0 KiB
Go
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
|
|
}
|