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

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
}