mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
e1b439d6a5
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":74}
481 lines
14 KiB
Go
481 lines
14 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package tls
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"strings"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
)
|
|
|
|
// CertificateInput TLS 证书创建/更新请求。
|
|
type CertificateInput struct {
|
|
Name string `json:"name"`
|
|
CertPEM string `json:"cert_pem"`
|
|
KeyPEM string `json:"key_pem"`
|
|
Remark string `json:"remark"`
|
|
}
|
|
|
|
// CertificateContent TLS 证书 PEM 内容(仅 /content 端点返回)。
|
|
type CertificateContent struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
CertPEM string `json:"cert_pem"`
|
|
KeyPEM string `json:"key_pem"`
|
|
Remark string `json:"remark"`
|
|
Provider string `json:"provider"`
|
|
AcmeAccountID uint `json:"acme_account_id"`
|
|
DNSAccountID uint `json:"dns_account_id"`
|
|
KeyAlgorithm string `json:"key_algorithm"`
|
|
AutoRenew bool `json:"auto_renew"`
|
|
PrimaryDomain string `json:"primary_domain"`
|
|
OtherDomains string `json:"other_domains"`
|
|
DisableCNAME bool `json:"disable_cname"`
|
|
SkipDNS bool `json:"skip_dns"`
|
|
DNS1 string `json:"dns1"`
|
|
DNS2 string `json:"dns2"`
|
|
ApplyStatus string `json:"apply_status"`
|
|
ApplyMessage string `json:"apply_message"`
|
|
}
|
|
|
|
// ApplyInput ACME 证书申请/更新请求。
|
|
type ApplyInput struct {
|
|
Name string `json:"name"`
|
|
Remark string `json:"remark"`
|
|
AcmeAccountID uint `json:"acme_account_id"`
|
|
DNSAccountID uint `json:"dns_account_id"`
|
|
KeyAlgorithm string `json:"key_algorithm"`
|
|
AutoRenew bool `json:"auto_renew"`
|
|
PrimaryDomain string `json:"primary_domain"`
|
|
OtherDomains string `json:"other_domains"`
|
|
DisableCNAME bool `json:"disable_cname"`
|
|
SkipDNS bool `json:"skip_dns"`
|
|
DNS1 string `json:"dns1"`
|
|
DNS2 string `json:"dns2"`
|
|
}
|
|
|
|
// DNSAccountInput DNS 账号创建/更新请求。
|
|
type DNSAccountInput struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Authorization string `json:"authorization"`
|
|
}
|
|
|
|
// ListCertificates 列出全部证书(不含 PEM)。
|
|
func ListCertificates(ctx context.Context) ([]model.TLSCertificate, error) {
|
|
return repository.ListTLSCertificates(ctx)
|
|
}
|
|
|
|
// GetCertificate 获取证书详情(不含 PEM)。
|
|
func GetCertificate(ctx context.Context, id uint) (*model.TLSCertificate, error) {
|
|
return repository.GetTLSCertificateByID(ctx, id)
|
|
}
|
|
|
|
// GetCertificateContent 获取证书 PEM 内容。
|
|
func GetCertificateContent(ctx context.Context, id uint) (*CertificateContent, error) {
|
|
certificate, err := repository.GetTLSCertificateByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyPEM, err := openSensitive(certificate.KeyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CertificateContent{
|
|
ID: certificate.ID,
|
|
Name: certificate.Name,
|
|
CertPEM: certificate.CertPEM,
|
|
KeyPEM: keyPEM,
|
|
Remark: certificate.Remark,
|
|
Provider: certificate.Provider,
|
|
AcmeAccountID: certificate.AcmeAccountID,
|
|
DNSAccountID: certificate.DNSAccountID,
|
|
KeyAlgorithm: certificate.KeyAlgorithm,
|
|
AutoRenew: certificate.AutoRenew,
|
|
PrimaryDomain: certificate.PrimaryDomain,
|
|
OtherDomains: certificate.OtherDomains,
|
|
DisableCNAME: certificate.DisableCNAME,
|
|
SkipDNS: certificate.SkipDNS,
|
|
DNS1: certificate.DNS1,
|
|
DNS2: certificate.DNS2,
|
|
ApplyStatus: certificate.ApplyStatus,
|
|
ApplyMessage: certificate.ApplyMessage,
|
|
}, nil
|
|
}
|
|
|
|
// CreateCertificate 从 PEM 创建证书。
|
|
func CreateCertificate(ctx context.Context, input CertificateInput) (*model.TLSCertificate, error) {
|
|
certificate, err := buildCertificate(ctx, nil, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = repository.CreateTLSCertificateRecord(ctx, certificate); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return nil, errors.New(errCertificateNameExists)
|
|
}
|
|
return nil, err
|
|
}
|
|
return sanitizeCertificateForResponse(certificate), nil
|
|
}
|
|
|
|
// CreateCertificateFromFiles 从上传文件创建证书。
|
|
func CreateCertificateFromFiles(ctx context.Context, name string, certFile *multipart.FileHeader, keyFile *multipart.FileHeader, remark string) (*model.TLSCertificate, error) {
|
|
if certFile == nil || keyFile == nil {
|
|
return nil, errors.New(errCertificateFilesRequired)
|
|
}
|
|
certContent, err := readMultipartFile(certFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyContent, err := readMultipartFile(keyFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return CreateCertificate(ctx, CertificateInput{
|
|
Name: name,
|
|
CertPEM: certContent,
|
|
KeyPEM: keyContent,
|
|
Remark: remark,
|
|
})
|
|
}
|
|
|
|
// UpdateCertificate 更新上传证书。
|
|
func UpdateCertificate(ctx context.Context, id uint, input CertificateInput) (*model.TLSCertificate, error) {
|
|
existing, err := repository.GetTLSCertificateByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
certificate, err := buildCertificate(ctx, existing, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = repository.SaveTLSCertificate(ctx, certificate); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return nil, errors.New(errCertificateNameExists)
|
|
}
|
|
return nil, err
|
|
}
|
|
return sanitizeCertificateForResponse(certificate), nil
|
|
}
|
|
|
|
// DeleteCertificate 删除证书。
|
|
func DeleteCertificate(ctx context.Context, id uint) error {
|
|
if err := ensureCertificateNotReferenced(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := repository.GetTLSCertificateByID(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
return repository.DeleteTLSCertificateRecord(ctx, id)
|
|
}
|
|
|
|
// ApplyCertificate 申请 ACME 证书。
|
|
func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertificate, error) {
|
|
cert := &model.TLSCertificate{
|
|
Provider: tlsProviderACME,
|
|
CertPEM: " ",
|
|
KeyPEM: " ",
|
|
}
|
|
fillAcmeCertificateFields(cert, input)
|
|
if cert.Name == "" {
|
|
return nil, errors.New(errCertificateNameRequired)
|
|
}
|
|
if err := repository.CreateTLSCertificateRecord(ctx, cert); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return nil, errors.New(errCertificateNameExists)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// 先取响应快照再启动异步续签:sanitize 会整体拷贝 cert,若与异步 goroutine
|
|
// 的字段写入并发会构成数据竞争(生产真实问题)。
|
|
returned := sanitizeCertificateForResponse(cert)
|
|
|
|
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
|
|
go func(c *model.TLSCertificate) {
|
|
asyncCtx := context.WithoutCancel(ctx)
|
|
_ = obtainFn(asyncCtx, c)
|
|
}(cert)
|
|
|
|
return returned, nil
|
|
}
|
|
|
|
// UpdateACMECertificate 更新 ACME 证书配置。
|
|
func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
|
cert, err := repository.GetTLSCertificateByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cert.Provider != tlsProviderACME {
|
|
return nil, errors.New(errCertificateOnlyACME)
|
|
}
|
|
fillAcmeCertificateFields(cert, input)
|
|
if cert.Name == "" {
|
|
return nil, errors.New(errCertificateNameRequired)
|
|
}
|
|
if err := repository.SaveTLSCertificate(ctx, cert); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return nil, errors.New(errCertificateNameExists)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
returned := sanitizeCertificateForResponse(cert)
|
|
|
|
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
|
|
go func(c *model.TLSCertificate) {
|
|
asyncCtx := context.WithoutCancel(ctx)
|
|
_ = obtainFn(asyncCtx, c)
|
|
}(cert)
|
|
|
|
return returned, nil
|
|
}
|
|
|
|
// ConvertCertificateToACME 将上传证书转为 ACME 管理。
|
|
func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
|
cert, err := repository.GetTLSCertificateByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cert.Provider != "upload" {
|
|
return nil, errors.New(errCertificateOnlyUploadConvert)
|
|
}
|
|
if cert.ApplyStatus == tlsApplyStatusApplying {
|
|
return nil, errors.New(errCertificateAlreadyApplying)
|
|
}
|
|
fillAcmeCertificateFields(cert, input)
|
|
if cert.Name == "" {
|
|
return nil, errors.New(errCertificateNameRequired)
|
|
}
|
|
cert.ApplyMessage = ""
|
|
if err := repository.SaveTLSCertificate(ctx, cert); err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return nil, errors.New(errCertificateNameExists)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
|
|
go func(c *model.TLSCertificate) {
|
|
asyncCtx := context.WithoutCancel(ctx)
|
|
if err := obtainFn(asyncCtx, c); err != nil {
|
|
return
|
|
}
|
|
latest, err := repository.GetTLSCertificateByID(asyncCtx, c.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
latest.Provider = tlsProviderACME
|
|
latest.ApplyStatus = tlsApplyStatusReady
|
|
latest.ApplyMessage = ""
|
|
_ = repository.SaveTLSCertificate(asyncCtx, latest)
|
|
}(cert)
|
|
|
|
return sanitizeCertificateForResponse(cert), nil
|
|
}
|
|
|
|
// RenewCertificate 续期 ACME 证书。
|
|
func RenewCertificate(ctx context.Context, id uint) (*model.TLSCertificate, error) {
|
|
cert, err := repository.GetTLSCertificateByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cert.Provider != tlsProviderACME {
|
|
return nil, errors.New(errCertificateOnlyACMERenew)
|
|
}
|
|
|
|
payload, err := json.Marshal(SSLSingleRenewPayload{ID: id})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = task.DispatchTask(ctx, TaskTypeSSLSingleRenew, payload, "manual")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cert.ApplyStatus = tlsApplyStatusApplying
|
|
cert.ApplyMessage = ""
|
|
if err := repository.SaveTLSCertificate(ctx, cert); err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeCertificateForResponse(cert), nil
|
|
}
|
|
|
|
// ListDNSAccounts 列出 DNS 账号。
|
|
func ListDNSAccounts(ctx context.Context) ([]model.DNSAccount, error) {
|
|
return repository.ListDNSAccounts(ctx)
|
|
}
|
|
|
|
// CreateDNSAccount 创建 DNS 账号。
|
|
func CreateDNSAccount(ctx context.Context, input DNSAccountInput) (*model.DNSAccount, error) {
|
|
authorization, err := sealSensitive(strings.TrimSpace(input.Authorization))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
account := &model.DNSAccount{
|
|
Name: strings.TrimSpace(input.Name),
|
|
Type: strings.TrimSpace(input.Type),
|
|
Authorization: authorization,
|
|
}
|
|
if account.Name == "" || account.Type == "" || authorization == "" {
|
|
return nil, errors.New("DNS 账号参数不完整")
|
|
}
|
|
if err := repository.CreateDNSAccountRecord(ctx, account); err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeDNSAccountForResponse(account), nil
|
|
}
|
|
|
|
// UpdateDNSAccount 更新 DNS 账号。
|
|
func UpdateDNSAccount(ctx context.Context, id uint, input DNSAccountInput) (*model.DNSAccount, error) {
|
|
account, err := repository.GetDNSAccountByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
authorization, err := sealSensitive(strings.TrimSpace(input.Authorization))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
account.Name = strings.TrimSpace(input.Name)
|
|
account.Type = strings.TrimSpace(input.Type)
|
|
account.Authorization = authorization
|
|
if account.Name == "" || account.Type == "" || authorization == "" {
|
|
return nil, errors.New("DNS 账号参数不完整")
|
|
}
|
|
if err := repository.SaveDNSAccount(ctx, account); err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeDNSAccountForResponse(account), nil
|
|
}
|
|
|
|
// DeleteDNSAccount 删除 DNS 账号。
|
|
func DeleteDNSAccount(ctx context.Context, id uint) error {
|
|
if _, err := repository.GetDNSAccountByID(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
count, err := repository.CountTLSCertificatesByDNSAccountID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return errors.New(errDNSAccountInUse)
|
|
}
|
|
return repository.DeleteDNSAccountRecord(ctx, id)
|
|
}
|
|
|
|
// GetDefaultAcmeAccount 获取默认 ACME 账号。
|
|
func GetDefaultAcmeAccount(ctx context.Context) (*model.AcmeAccount, error) {
|
|
account, err := repository.GetDefaultAcmeAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeAcmeAccountForResponse(account), nil
|
|
}
|
|
|
|
func buildCertificate(_ context.Context, existing *model.TLSCertificate, input CertificateInput) (*model.TLSCertificate, error) {
|
|
name := strings.TrimSpace(input.Name)
|
|
certPEM := strings.TrimSpace(input.CertPEM)
|
|
keyPEM := strings.TrimSpace(input.KeyPEM)
|
|
remark := strings.TrimSpace(input.Remark)
|
|
if name == "" {
|
|
return nil, errors.New(errCertificateNameRequired)
|
|
}
|
|
if certPEM == "" || keyPEM == "" {
|
|
return nil, errors.New(errCertificateContentRequired)
|
|
}
|
|
parsed, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", errCertificateContentInvalid, err)
|
|
}
|
|
if len(parsed.Certificate) == 0 {
|
|
return nil, errors.New(errCertificateContentInvalid)
|
|
}
|
|
leaf, err := parseLeafCertificate(certPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sealedKey, err := sealSensitive(keyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing == nil {
|
|
existing = &model.TLSCertificate{
|
|
Provider: "upload",
|
|
ApplyStatus: tlsApplyStatusReady,
|
|
}
|
|
}
|
|
existing.Name = name
|
|
existing.CertPEM = certPEM
|
|
existing.KeyPEM = sealedKey
|
|
existing.NotBefore = leaf.NotBefore
|
|
existing.NotAfter = leaf.NotAfter
|
|
existing.Remark = remark
|
|
return existing, nil
|
|
}
|
|
|
|
func fillAcmeCertificateFields(cert *model.TLSCertificate, input ApplyInput) {
|
|
cert.Name = strings.TrimSpace(input.Name)
|
|
cert.Remark = strings.TrimSpace(input.Remark)
|
|
cert.AcmeAccountID = input.AcmeAccountID
|
|
cert.DNSAccountID = input.DNSAccountID
|
|
cert.KeyAlgorithm = input.KeyAlgorithm
|
|
cert.AutoRenew = input.AutoRenew
|
|
cert.PrimaryDomain = strings.TrimSpace(input.PrimaryDomain)
|
|
cert.OtherDomains = strings.TrimSpace(input.OtherDomains)
|
|
cert.DisableCNAME = input.DisableCNAME
|
|
cert.SkipDNS = input.SkipDNS
|
|
cert.DNS1 = strings.TrimSpace(input.DNS1)
|
|
cert.DNS2 = strings.TrimSpace(input.DNS2)
|
|
cert.ApplyStatus = tlsApplyStatusApplying
|
|
}
|
|
|
|
func ensureCertificateNotReferenced(ctx context.Context, id uint) error {
|
|
count, err := repository.CountZoneDomainsByCertificateID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return errors.New(errCertificateDeleteReferenced)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sanitizeCertificateForResponse(certificate *model.TLSCertificate) *model.TLSCertificate {
|
|
if certificate == nil {
|
|
return nil
|
|
}
|
|
certCopy := *certificate
|
|
certCopy.CertPEM = ""
|
|
certCopy.KeyPEM = ""
|
|
return &certCopy
|
|
}
|
|
|
|
func sanitizeDNSAccountForResponse(account *model.DNSAccount) *model.DNSAccount {
|
|
if account == nil {
|
|
return nil
|
|
}
|
|
certCopy := *account
|
|
certCopy.Authorization = ""
|
|
return &certCopy
|
|
}
|
|
|
|
func sanitizeAcmeAccountForResponse(account *model.AcmeAccount) *model.AcmeAccount {
|
|
if account == nil {
|
|
return nil
|
|
}
|
|
certCopy := *account
|
|
certCopy.PrivateKey = ""
|
|
return &certCopy
|
|
}
|