mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
migrate
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tls
|
||||
|
||||
const (
|
||||
errCertificateNameRequired = "certificate name cannot be empty"
|
||||
errCertificateNameExists = "certificate name already exists"
|
||||
errCertificateContentRequired = "certificate content and key content cannot be empty"
|
||||
errCertificateContentInvalid = "certificate or key format is invalid"
|
||||
errCertificateDeleteReferenced = "certificate is still referenced by proxy routes"
|
||||
errCertificateOnlyACME = "only acme certificates can be updated via this endpoint"
|
||||
errCertificateOnlyUploadConvert = "only uploaded certificates can be converted to acme"
|
||||
errCertificateAlreadyApplying = "certificate is already applying"
|
||||
errCertificateOnlyACMERenew = "only acme certificates can be renewed"
|
||||
errCertificateFilesRequired = "certificate file and key file cannot be empty"
|
||||
errCertificatePEMInvalid = "证书 PEM 内容不合法"
|
||||
|
||||
errManagedDomainRequired = "域名不能为空"
|
||||
errManagedDomainInvalid = "域名格式不合法"
|
||||
errManagedDomainWildcardInvalid = "通配符域名仅支持 *.example.com 格式"
|
||||
errManagedDomainExists = "域名已存在"
|
||||
errManagedDomainCertNotFound = "所选证书不存在"
|
||||
|
||||
errDNSAccountInUse = "该 DNS 账号已被证书使用,无法删除"
|
||||
|
||||
errACMENotImplemented = "ACME certificate obtain is not implemented yet"
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tls
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseLeafCertificate(certPEM string) (*x509.Certificate, error) {
|
||||
certPEMBlock, _ := pem.Decode([]byte(certPEM))
|
||||
if certPEMBlock == nil {
|
||||
return nil, errors.New(errCertificatePEMInvalid)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(certPEMBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
func readMultipartFile(fileHeader *multipart.FileHeader) (string, error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unique")
|
||||
}
|
||||
|
||||
func decodeStoredDomainCertIDs(raw string, domainCount int) ([]uint, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var domainCertIDs []uint
|
||||
if err := json.Unmarshal([]byte(text), &domainCertIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if domainCount > 0 && len(domainCertIDs) != domainCount {
|
||||
return nil, fmt.Errorf("domain_cert_ids length mismatch")
|
||||
}
|
||||
return domainCertIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
// 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/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 model.ListTLSCertificates(ctx)
|
||||
}
|
||||
|
||||
// GetCertificate 获取证书详情(不含 PEM)。
|
||||
func GetCertificate(ctx context.Context, id uint) (*model.TLSCertificate, error) {
|
||||
return model.GetTLSCertificateByID(ctx, id)
|
||||
}
|
||||
|
||||
// GetCertificateContent 获取证书 PEM 内容。
|
||||
func GetCertificateContent(ctx context.Context, id uint) (*CertificateContent, error) {
|
||||
certificate, err := model.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 = model.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 := model.GetTLSCertificateByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certificate, err := buildCertificate(ctx, existing, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = model.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 := model.GetTLSCertificateByID(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return model.DeleteTLSCertificateRecord(ctx, id)
|
||||
}
|
||||
|
||||
// ApplyCertificate 申请 ACME 证书(当前为占位实现)。
|
||||
func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertificate, error) {
|
||||
cert := &model.TLSCertificate{
|
||||
Provider: "acme",
|
||||
CertPEM: " ",
|
||||
KeyPEM: " ",
|
||||
}
|
||||
fillAcmeCertificateFields(cert, input)
|
||||
if cert.Name == "" {
|
||||
return nil, errors.New(errCertificateNameRequired)
|
||||
}
|
||||
if err := model.CreateTLSCertificateRecord(ctx, cert); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New(errCertificateNameExists)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return markACMEStubFailure(ctx, cert)
|
||||
}
|
||||
|
||||
// UpdateACMECertificate 更新 ACME 证书配置(当前为占位实现)。
|
||||
func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
||||
cert, err := model.GetTLSCertificateByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert.Provider != "acme" {
|
||||
return nil, errors.New(errCertificateOnlyACME)
|
||||
}
|
||||
fillAcmeCertificateFields(cert, input)
|
||||
if cert.Name == "" {
|
||||
return nil, errors.New(errCertificateNameRequired)
|
||||
}
|
||||
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New(errCertificateNameExists)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return markACMEStubFailure(ctx, cert)
|
||||
}
|
||||
|
||||
// ConvertCertificateToACME 将上传证书转为 ACME 管理(当前为占位实现)。
|
||||
func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*model.TLSCertificate, error) {
|
||||
cert, err := model.GetTLSCertificateByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert.Provider != "upload" {
|
||||
return nil, errors.New(errCertificateOnlyUploadConvert)
|
||||
}
|
||||
if cert.ApplyStatus == "applying" {
|
||||
return nil, errors.New(errCertificateAlreadyApplying)
|
||||
}
|
||||
fillAcmeCertificateFields(cert, input)
|
||||
if cert.Name == "" {
|
||||
return nil, errors.New(errCertificateNameRequired)
|
||||
}
|
||||
cert.ApplyMessage = ""
|
||||
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New(errCertificateNameExists)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return markACMEStubFailure(ctx, cert)
|
||||
}
|
||||
|
||||
// RenewCertificate 续期 ACME 证书(当前为占位实现)。
|
||||
func RenewCertificate(ctx context.Context, id uint) (*model.TLSCertificate, error) {
|
||||
cert, err := model.GetTLSCertificateByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert.Provider != "acme" {
|
||||
return nil, errors.New(errCertificateOnlyACMERenew)
|
||||
}
|
||||
cert.ApplyStatus = "applying"
|
||||
cert.ApplyMessage = ""
|
||||
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return markACMEStubFailure(ctx, cert)
|
||||
}
|
||||
|
||||
// ListDNSAccounts 列出 DNS 账号。
|
||||
func ListDNSAccounts(ctx context.Context) ([]model.DNSAccount, error) {
|
||||
return model.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 := model.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 := model.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 := model.SaveDNSAccount(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sanitizeDNSAccountForResponse(account), nil
|
||||
}
|
||||
|
||||
// DeleteDNSAccount 删除 DNS 账号。
|
||||
func DeleteDNSAccount(ctx context.Context, id uint) error {
|
||||
if _, err := model.GetDNSAccountByID(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := model.CountTLSCertificatesByDNSAccountID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errDNSAccountInUse)
|
||||
}
|
||||
return model.DeleteDNSAccountRecord(ctx, id)
|
||||
}
|
||||
|
||||
// GetDefaultAcmeAccount 获取默认 ACME 账号。
|
||||
func GetDefaultAcmeAccount(ctx context.Context) (*model.AcmeAccount, error) {
|
||||
account, err := model.GetDefaultAcmeAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sanitizeAcmeAccountForResponse(account), nil
|
||||
}
|
||||
|
||||
func buildCertificate(ctx 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: "ready",
|
||||
}
|
||||
}
|
||||
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.Provider = "acme"
|
||||
cert.ApplyStatus = "applying"
|
||||
}
|
||||
|
||||
func markACMEStubFailure(ctx context.Context, cert *model.TLSCertificate) (*model.TLSCertificate, error) {
|
||||
cert.ApplyStatus = "failed"
|
||||
cert.ApplyMessage = errACMENotImplemented
|
||||
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sanitizeCertificateForResponse(cert), nil
|
||||
}
|
||||
|
||||
func ensureCertificateNotReferenced(ctx context.Context, id uint) error {
|
||||
routes, err := model.ListTLSProxyRouteRefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.CertID != nil && *route.CertID == id {
|
||||
return errors.New(errCertificateDeleteReferenced)
|
||||
}
|
||||
if strings.TrimSpace(route.CertIDs) == "" {
|
||||
continue
|
||||
}
|
||||
var certIDs []uint
|
||||
if err := json.Unmarshal([]byte(route.CertIDs), &certIDs); err != nil {
|
||||
return fmt.Errorf("proxy route %d cert_ids payload is invalid: %w", route.ID, err)
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
if certID == id {
|
||||
return errors.New(errCertificateDeleteReferenced)
|
||||
}
|
||||
}
|
||||
domainCertIDs, err := decodeStoredDomainCertIDs(route.DomainCertIDs, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxy route %d domain_cert_ids payload is invalid: %w", route.ID, err)
|
||||
}
|
||||
for _, certID := range domainCertIDs {
|
||||
if certID == id {
|
||||
return errors.New(errCertificateDeleteReferenced)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeCertificateForResponse(certificate *model.TLSCertificate) *model.TLSCertificate {
|
||||
if certificate == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *certificate
|
||||
copy.CertPEM = ""
|
||||
copy.KeyPEM = ""
|
||||
return ©
|
||||
}
|
||||
|
||||
func sanitizeDNSAccountForResponse(account *model.DNSAccount) *model.DNSAccount {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *account
|
||||
copy.Authorization = ""
|
||||
return ©
|
||||
}
|
||||
|
||||
func sanitizeAcmeAccountForResponse(account *model.AcmeAccount) *model.AcmeAccount {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *account
|
||||
copy.PrivateKey = ""
|
||||
return ©
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupTLSTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sqliteDB.AutoMigrate(
|
||||
&model.TLSCertificate{},
|
||||
&model.ManagedDomain{},
|
||||
&model.DNSAccount{},
|
||||
&model.AcmeAccount{},
|
||||
))
|
||||
|
||||
db.SetDB(sqliteDB)
|
||||
oldSecret := config.Config.App.SessionSecret
|
||||
config.Config.App.SessionSecret = "test_session_secret_for_tls_encryption"
|
||||
return func() {
|
||||
db.SetDB(nil)
|
||||
config.Config.App.SessionSecret = oldSecret
|
||||
}
|
||||
}
|
||||
|
||||
func generateTestCertificatePair(t *testing.T, dnsNames []string) (string, string) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{
|
||||
CommonName: dnsNames[0],
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
return string(certPEM), string(keyPEM)
|
||||
}
|
||||
|
||||
func TestCreateManagedDomain(t *testing.T) {
|
||||
cleanup := setupTLSTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
certPEM, keyPEM := generateTestCertificatePair(t, []string{"api.example.com"})
|
||||
certificate, err := CreateCertificate(ctx, CertificateInput{
|
||||
Name: "api-cert",
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
certID := certificate.ID
|
||||
domain, err := CreateManagedDomain(ctx, ManagedDomainInput{
|
||||
Domain: "api.example.com",
|
||||
CertID: &certID,
|
||||
Enabled: true,
|
||||
Remark: "primary api",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, domain.ID)
|
||||
assert.Equal(t, "api.example.com", domain.Domain)
|
||||
assert.Equal(t, certID, *domain.CertID)
|
||||
assert.True(t, domain.Enabled)
|
||||
assert.Equal(t, "primary api", domain.Remark)
|
||||
|
||||
_, err = CreateManagedDomain(ctx, ManagedDomainInput{
|
||||
Domain: "api.example.com",
|
||||
Enabled: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, errManagedDomainExists, err.Error())
|
||||
}
|
||||
|
||||
func TestCreateManagedDomainRejectsInvalidWildcard(t *testing.T) {
|
||||
cleanup := setupTLSTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := CreateManagedDomain(ctx, ManagedDomainInput{
|
||||
Domain: "*.*.example.com",
|
||||
Enabled: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, errManagedDomainWildcardInvalid, err.Error())
|
||||
}
|
||||
|
||||
func TestCreateCertificateEncryptsPrivateKey(t *testing.T) {
|
||||
cleanup := setupTLSTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
certPEM, keyPEM := generateTestCertificatePair(t, []string{"secure.example.com"})
|
||||
certificate, err := CreateCertificate(ctx, CertificateInput{
|
||||
Name: "secure-cert",
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, err := model.GetTLSCertificateByID(ctx, certificate.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, keyPEM, stored.KeyPEM)
|
||||
assert.Contains(t, stored.KeyPEM, sensitiveValuePrefix)
|
||||
|
||||
content, err := GetCertificateContent(ctx, certificate.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(keyPEM), strings.TrimSpace(content.KeyPEM))
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
managedDomainMatchTypeExact = "exact"
|
||||
managedDomainMatchTypeWildcard = "wildcard"
|
||||
)
|
||||
|
||||
// ManagedDomainInput 托管域名创建/更新请求。
|
||||
type ManagedDomainInput struct {
|
||||
Domain string `json:"domain"`
|
||||
CertID *uint `json:"cert_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// ManagedDomainMatchCandidate 证书匹配候选。
|
||||
type ManagedDomainMatchCandidate struct {
|
||||
ManagedDomainID uint `json:"managed_domain_id"`
|
||||
Domain string `json:"domain"`
|
||||
MatchType string `json:"match_type"`
|
||||
CertificateID uint `json:"certificate_id"`
|
||||
CertificateName string `json:"certificate_name"`
|
||||
}
|
||||
|
||||
// ManagedDomainMatchResult 证书匹配结果。
|
||||
type ManagedDomainMatchResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Matched bool `json:"matched"`
|
||||
Candidate *ManagedDomainMatchCandidate `json:"candidate,omitempty"`
|
||||
Candidates []ManagedDomainMatchCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// ListManagedDomains 列出托管域名。
|
||||
func ListManagedDomains(ctx context.Context) ([]model.ManagedDomain, error) {
|
||||
return model.ListManagedDomains(ctx)
|
||||
}
|
||||
|
||||
// CreateManagedDomain 创建托管域名。
|
||||
func CreateManagedDomain(ctx context.Context, input ManagedDomainInput) (*model.ManagedDomain, error) {
|
||||
domain, err := buildManagedDomain(ctx, nil, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = model.CreateManagedDomainRecord(ctx, domain); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New(errManagedDomainExists)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
// UpdateManagedDomain 更新托管域名。
|
||||
func UpdateManagedDomain(ctx context.Context, id uint, input ManagedDomainInput) (*model.ManagedDomain, error) {
|
||||
domain, err := model.GetManagedDomainByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domain, err = buildManagedDomain(ctx, domain, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = model.SaveManagedDomain(ctx, domain); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New(errManagedDomainExists)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
// DeleteManagedDomain 删除托管域名。
|
||||
func DeleteManagedDomain(ctx context.Context, id uint) error {
|
||||
if _, err := model.GetManagedDomainByID(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return model.DeleteManagedDomainRecord(ctx, id)
|
||||
}
|
||||
|
||||
// MatchManagedDomainCertificate 为域名匹配证书。
|
||||
func MatchManagedDomainCertificate(ctx context.Context, rawDomain string) (*ManagedDomainMatchResult, error) {
|
||||
domain := normalizeManagedDomain(rawDomain)
|
||||
if err := validateManagedDomainPattern(domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
managedDomains, err := model.ListEnabledManagedDomainsWithCertificate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates := make([]ManagedDomainMatchCandidate, 0)
|
||||
for _, item := range managedDomains {
|
||||
if item.CertID == nil || *item.CertID == 0 {
|
||||
continue
|
||||
}
|
||||
matchType := detectManagedDomainMatchType(item.Domain, domain)
|
||||
if matchType == "" {
|
||||
continue
|
||||
}
|
||||
certificate, err := model.GetTLSCertificateByID(ctx, *item.CertID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("托管域名 %s 关联证书不存在", item.Domain)
|
||||
}
|
||||
candidates = append(candidates, ManagedDomainMatchCandidate{
|
||||
ManagedDomainID: item.ID,
|
||||
Domain: item.Domain,
|
||||
MatchType: matchType,
|
||||
CertificateID: certificate.ID,
|
||||
CertificateName: certificate.Name,
|
||||
})
|
||||
}
|
||||
sortManagedDomainCandidates(candidates)
|
||||
result := &ManagedDomainMatchResult{
|
||||
Domain: domain,
|
||||
Matched: len(candidates) > 0,
|
||||
Candidates: candidates,
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
candidate := candidates[0]
|
||||
result.Candidate = &candidate
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildManagedDomain(ctx context.Context, existing *model.ManagedDomain, input ManagedDomainInput) (*model.ManagedDomain, error) {
|
||||
domain := normalizeManagedDomain(input.Domain)
|
||||
remark := strings.TrimSpace(input.Remark)
|
||||
if err := validateManagedDomainPattern(domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.CertID != nil && *input.CertID != 0 {
|
||||
if _, err := model.GetTLSCertificateByID(ctx, *input.CertID); err != nil {
|
||||
return nil, errors.New(errManagedDomainCertNotFound)
|
||||
}
|
||||
} else {
|
||||
input.CertID = nil
|
||||
}
|
||||
if existing == nil {
|
||||
existing = &model.ManagedDomain{}
|
||||
}
|
||||
existing.Domain = domain
|
||||
existing.CertID = input.CertID
|
||||
existing.Enabled = input.Enabled
|
||||
existing.Remark = remark
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func normalizeManagedDomain(domain string) string {
|
||||
return strings.ToLower(strings.TrimSpace(domain))
|
||||
}
|
||||
|
||||
func validateManagedDomainPattern(domain string) error {
|
||||
if domain == "" {
|
||||
return errors.New(errManagedDomainRequired)
|
||||
}
|
||||
if strings.Contains(domain, "://") || strings.Contains(domain, "/") {
|
||||
return errors.New(errManagedDomainInvalid)
|
||||
}
|
||||
if strings.Contains(domain, "*") {
|
||||
if !strings.HasPrefix(domain, "*.") || strings.Count(domain, "*") != 1 {
|
||||
return errors.New(errManagedDomainWildcardInvalid)
|
||||
}
|
||||
return validateHostname(strings.TrimPrefix(domain, "*."))
|
||||
}
|
||||
return validateHostname(domain)
|
||||
}
|
||||
|
||||
func validateHostname(domain string) error {
|
||||
if domain == "" {
|
||||
return errors.New(errManagedDomainRequired)
|
||||
}
|
||||
if len(domain) > 253 {
|
||||
return errors.New(errManagedDomainInvalid)
|
||||
}
|
||||
labels := strings.Split(domain, ".")
|
||||
if len(labels) < 2 {
|
||||
return errors.New(errManagedDomainInvalid)
|
||||
}
|
||||
for _, label := range labels {
|
||||
if len(label) == 0 || len(label) > 63 {
|
||||
return errors.New(errManagedDomainInvalid)
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return errors.New(errManagedDomainInvalid)
|
||||
}
|
||||
for _, r := range label {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||
continue
|
||||
}
|
||||
return errors.New(errManagedDomainInvalid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectManagedDomainMatchType(pattern string, domain string) string {
|
||||
if pattern == domain {
|
||||
return managedDomainMatchTypeExact
|
||||
}
|
||||
if !strings.HasPrefix(pattern, "*.") {
|
||||
return ""
|
||||
}
|
||||
suffix := strings.TrimPrefix(pattern, "*.")
|
||||
if !strings.HasSuffix(domain, "."+suffix) {
|
||||
return ""
|
||||
}
|
||||
prefix := strings.TrimSuffix(domain, "."+suffix)
|
||||
if prefix == "" || strings.Contains(prefix, ".") {
|
||||
return ""
|
||||
}
|
||||
return managedDomainMatchTypeWildcard
|
||||
}
|
||||
|
||||
func sortManagedDomainCandidates(candidates []ManagedDomainMatchCandidate) {
|
||||
sort.Slice(candidates, func(i int, j int) bool {
|
||||
left := candidates[i]
|
||||
right := candidates[j]
|
||||
if left.MatchType != right.MatchType {
|
||||
return left.MatchType == managedDomainMatchTypeExact
|
||||
}
|
||||
if len(left.Domain) != len(right.Domain) {
|
||||
return len(left.Domain) > len(right.Domain)
|
||||
}
|
||||
return left.ManagedDomainID < right.ManagedDomainID
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tls
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func handleLogicError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
compat.Fail(c, "记录不存在")
|
||||
return true
|
||||
}
|
||||
compat.Fail(c, err.Error())
|
||||
return true
|
||||
}
|
||||
|
||||
// GetCertificates 列出 TLS 证书。
|
||||
func GetCertificates(c *gin.Context) {
|
||||
certificates, err := ListCertificates(c.Request.Context())
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificates)
|
||||
}
|
||||
|
||||
// GetCertificateDetail 获取 TLS 证书详情。
|
||||
func GetCertificateDetail(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
certificate, err := GetCertificate(c.Request.Context(), id)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// GetCertificateContentHandler 获取 TLS 证书 PEM 内容。
|
||||
func GetCertificateContentHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
content, err := GetCertificateContent(c.Request.Context(), id)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, content)
|
||||
}
|
||||
|
||||
// CreateCertificateHandler 从 PEM 创建证书。
|
||||
func CreateCertificateHandler(c *gin.Context) {
|
||||
var input CertificateInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := CreateCertificate(c.Request.Context(), input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// UpdateCertificateHandler 更新证书。
|
||||
func UpdateCertificateHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input CertificateInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := UpdateCertificate(c.Request.Context(), id, input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// ImportCertificateFile 从文件导入证书。
|
||||
func ImportCertificateFile(c *gin.Context) {
|
||||
name := c.PostForm("name")
|
||||
remark := c.PostForm("remark")
|
||||
certFile, err := c.FormFile("cert_file")
|
||||
if err != nil {
|
||||
compat.Fail(c, "缺少证书文件")
|
||||
return
|
||||
}
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err != nil {
|
||||
compat.Fail(c, "缺少私钥文件")
|
||||
return
|
||||
}
|
||||
certificate, err := CreateCertificateFromFiles(c.Request.Context(), name, certFile, keyFile, remark)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// DeleteCertificateHandler 删除证书。
|
||||
func DeleteCertificateHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := DeleteCertificate(c.Request.Context(), id); handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, nil)
|
||||
}
|
||||
|
||||
// ApplyCertificateHandler 申请 ACME 证书。
|
||||
func ApplyCertificateHandler(c *gin.Context) {
|
||||
var input ApplyInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := ApplyCertificate(c.Request.Context(), input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// UpdateACMECertificateHandler 更新 ACME 证书配置。
|
||||
func UpdateACMECertificateHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input ApplyInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := UpdateACMECertificate(c.Request.Context(), id, input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// ConvertCertificateToACMEHandler 将上传证书转为 ACME。
|
||||
func ConvertCertificateToACMEHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input ApplyInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := ConvertCertificateToACME(c.Request.Context(), id, input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// RenewCertificateHandler 续期 ACME 证书。
|
||||
func RenewCertificateHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
certificate, err := RenewCertificate(c.Request.Context(), id)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, certificate)
|
||||
}
|
||||
|
||||
// GetManagedDomains 列出托管域名。
|
||||
func GetManagedDomains(c *gin.Context) {
|
||||
domains, err := ListManagedDomains(c.Request.Context())
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, domains)
|
||||
}
|
||||
|
||||
// CreateManagedDomainHandler 创建托管域名。
|
||||
func CreateManagedDomainHandler(c *gin.Context) {
|
||||
var input ManagedDomainInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
domain, err := CreateManagedDomain(c.Request.Context(), input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, domain)
|
||||
}
|
||||
|
||||
// UpdateManagedDomainHandler 更新托管域名。
|
||||
func UpdateManagedDomainHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input ManagedDomainInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
domain, err := UpdateManagedDomain(c.Request.Context(), id, input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, domain)
|
||||
}
|
||||
|
||||
// DeleteManagedDomainHandler 删除托管域名。
|
||||
func DeleteManagedDomainHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := DeleteManagedDomain(c.Request.Context(), id); handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, nil)
|
||||
}
|
||||
|
||||
// MatchManagedDomainCertificateHandler 匹配域名证书。
|
||||
func MatchManagedDomainCertificateHandler(c *gin.Context) {
|
||||
domain := strings.TrimSpace(c.Query("domain"))
|
||||
result, err := MatchManagedDomainCertificate(c.Request.Context(), domain)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, result)
|
||||
}
|
||||
|
||||
// GetDNSAccounts 列出 DNS 账号。
|
||||
func GetDNSAccounts(c *gin.Context) {
|
||||
accounts, err := ListDNSAccounts(c.Request.Context())
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, accounts)
|
||||
}
|
||||
|
||||
// CreateDNSAccountHandler 创建 DNS 账号。
|
||||
func CreateDNSAccountHandler(c *gin.Context) {
|
||||
var input DNSAccountInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
account, err := CreateDNSAccount(c.Request.Context(), input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, account)
|
||||
}
|
||||
|
||||
// UpdateDNSAccountHandler 更新 DNS 账号。
|
||||
func UpdateDNSAccountHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input DNSAccountInput
|
||||
if !compat.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
account, err := UpdateDNSAccount(c.Request.Context(), id, input)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, account)
|
||||
}
|
||||
|
||||
// DeleteDNSAccountHandler 删除 DNS 账号。
|
||||
func DeleteDNSAccountHandler(c *gin.Context) {
|
||||
id, ok := compat.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := DeleteDNSAccount(c.Request.Context(), id); handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, nil)
|
||||
}
|
||||
|
||||
// GetDefaultAcmeAccountHandler 获取默认 ACME 账号。
|
||||
func GetDefaultAcmeAccountHandler(c *gin.Context) {
|
||||
account, err := GetDefaultAcmeAccount(c.Request.Context())
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
compat.OK(c, account)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tls
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
const sensitiveValuePrefix = "enc:v1:"
|
||||
|
||||
func sensitiveEncryptionKey() string {
|
||||
if config.Config == nil || strings.TrimSpace(config.Config.App.SessionSecret) == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(config.Config.App.SessionSecret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sealSensitive(plaintext string) (string, error) {
|
||||
plaintext = strings.TrimSpace(plaintext)
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
key := sensitiveEncryptionKey()
|
||||
if key == "" {
|
||||
return plaintext, nil
|
||||
}
|
||||
encrypted, err := util.Encrypt(key, plaintext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sensitiveValuePrefix + encrypted, nil
|
||||
}
|
||||
|
||||
func openSensitive(stored string) (string, error) {
|
||||
stored = strings.TrimSpace(stored)
|
||||
if stored == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(stored, sensitiveValuePrefix) {
|
||||
return stored, nil
|
||||
}
|
||||
key := sensitiveEncryptionKey()
|
||||
if key == "" {
|
||||
return "", errors.New("cannot decrypt sensitive field without session secret")
|
||||
}
|
||||
return util.Decrypt(key, strings.TrimPrefix(stored, sensitiveValuePrefix))
|
||||
}
|
||||
Reference in New Issue
Block a user