feat: configure HTTP/HTTPS public entry and managed certificates
This commit is contained in:
1 parent
35596cda9d
commit
88ea6b7854
21 files changed
+1309
-149
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user