refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
+262
View File
@@ -0,0 +1,262 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package acme
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"time"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/registration"
)
// AcmeUser implements lego's user interface.
type AcmeUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *AcmeUser) GetEmail() string {
return u.Email
}
func (u *AcmeUser) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
// CertificateResult holds obtained certificate material.
type CertificateResult struct {
CertPEM string
KeyPEM string
NotBefore time.Time
NotAfter time.Time
}
func parsePrivateKey(pemData string) (crypto.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemData))
if block == nil {
return nil, errors.New("failed to parse PEM block containing the key")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
return key, nil
}
return nil, errors.New("failed to parse private key")
}
func encodePrivateKey(key crypto.PrivateKey) (string, error) {
var pemBlock *pem.Block
switch k := key.(type) {
case *rsa.PrivateKey:
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
case *ecdsa.PrivateKey:
b, err := x509.MarshalECPrivateKey(k)
if err != nil {
return "", err
}
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
default:
return "", errors.New("unsupported key type")
}
return string(pem.EncodeToMemory(pemBlock)), nil
}
// GetOrCreateLegoClient returns a configured lego client and optional new account credentials.
func GetOrCreateLegoClient(acmeEmail, privateKeyPEM, accountURL string, keyAlgorithm string) (*lego.Client, *AcmeUser, string, string, error) {
var privateKey crypto.PrivateKey
var err error
var newPrivateKeyPEM string
var newAccountURL string
if privateKeyPEM == "" {
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, "", "", err
}
pemStr, err := encodePrivateKey(privateKey)
if err != nil {
return nil, nil, "", "", err
}
newPrivateKeyPEM = pemStr
} else {
privateKey, err = parsePrivateKey(privateKeyPEM)
if err != nil {
return nil, nil, "", "", err
}
}
user := &AcmeUser{
Email: acmeEmail,
key: privateKey,
}
if accountURL != "" {
user.Registration = &registration.Resource{
Body: acme.Account{
Status: "valid",
Contact: []string{"mailto:" + acmeEmail},
},
URI: accountURL,
}
}
config := lego.NewConfig(user)
config.CADirURL = lego.LEDirectoryProduction
switch keyAlgorithm {
case "RSA2048":
config.Certificate.KeyType = certcrypto.RSA2048
case "RSA4096":
config.Certificate.KeyType = certcrypto.RSA4096
case "EC256":
config.Certificate.KeyType = certcrypto.EC256
case "EC384":
config.Certificate.KeyType = certcrypto.EC384
default:
config.Certificate.KeyType = certcrypto.RSA2048
}
client, err := lego.NewClient(config)
if err != nil {
return nil, nil, "", "", err
}
if accountURL == "" {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, nil, "", "", err
}
user.Registration = reg
newAccountURL = reg.URI
}
return client, user, newPrivateKeyPEM, newAccountURL, nil
}
// SetupDNSProvider configures DNS-01 challenge for the lego client.
func SetupDNSProvider(client *lego.Client, dnsType, dnsAuth string, dns1, dns2 string, disableCNAME, skipDNS bool) error {
var provider challengeProvider
switch dnsType {
case "cloudflare":
var creds map[string]string
if err := json.Unmarshal([]byte(dnsAuth), &creds); err != nil {
return fmt.Errorf("failed to parse cloudflare credentials: %v", err)
}
config := cloudflare.NewDefaultConfig()
config.AuthToken = creds["api_token"]
p, err := cloudflare.NewDNSProviderConfig(config)
if err != nil {
return err
}
provider = p
default:
return fmt.Errorf("unsupported DNS provider: %s", dnsType)
}
var resolvers []string
if dns1 != "" {
resolvers = append(resolvers, dns1+":53")
}
if dns2 != "" {
resolvers = append(resolvers, dns2+":53")
}
var opts []dns01.ChallengeOption
if len(resolvers) > 0 {
opts = append(opts, dns01.AddRecursiveNameservers(resolvers))
}
if disableCNAME {
opts = append(opts, dns01.DisableCompletePropagationRequirement())
}
if skipDNS {
opts = append(opts, dns01.WrapPreCheck(func(domain, fqdn, value string, check dns01.PreCheckFunc) (bool, error) {
time.Sleep(20 * time.Second)
return true, nil
}))
}
return client.Challenge.SetDNS01Provider(provider, opts...)
}
type challengeProvider interface {
Present(domain, token, keyAuth string) error
CleanUp(domain, token, keyAuth string) error
}
// ObtainSSL obtains a certificate via ACME DNS-01 challenge.
func ObtainSSL(
acmeEmail, acmePrivateKeyPEM, acmeURL string,
dnsType, dnsAuth string,
dns1, dns2 string,
disableCNAME, skipDNS bool,
keyAlgorithm string,
domains []string,
) (string, string, *CertificateResult, error) {
client, _, newPrivateKeyPEM, newAccountURL, err := GetOrCreateLegoClient(acmeEmail, acmePrivateKeyPEM, acmeURL, keyAlgorithm)
if err != nil {
return "", "", nil, fmt.Errorf("failed to create ACME client: %w", err)
}
err = SetupDNSProvider(client, dnsType, dnsAuth, dns1, dns2, disableCNAME, skipDNS)
if err != nil {
return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to setup DNS provider: %w", err)
}
request := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to obtain certificate: %w", err)
}
result := &CertificateResult{
CertPEM: string(certificates.Certificate),
KeyPEM: string(certificates.PrivateKey),
}
certBlock, _ := pem.Decode(certificates.Certificate)
if certBlock != nil {
parsedCert, err := x509.ParseCertificate(certBlock.Bytes)
if err == nil {
result.NotBefore = parsedCert.NotBefore
result.NotAfter = parsedCert.NotAfter
}
}
return newAccountURL, newPrivateKeyPEM, result, nil
}
@@ -0,0 +1,155 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tls
import (
"context"
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSplitAcmeDomains(t *testing.T) {
assert.Equal(t, []string{"example.com"}, splitAcmeDomains("example.com", ""))
assert.Equal(t, []string{"example.com", "*.example.com"}, splitAcmeDomains("example.com", "*.example.com"))
assert.Equal(t, []string{"example.com", "www.example.com", "api.example.com"}, splitAcmeDomains("example.com", "www.example.com\napi.example.com"))
assert.Equal(t, []string{"example.com", "www.example.com", "api.example.com"}, splitAcmeDomains("example.com", "www.example.com, api.example.com"))
}
func TestCertificatesDueForRenewal(t *testing.T) {
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
certificates := []model.TLSCertificate{
{ID: 1, Provider: "acme", AutoRenew: true, ApplyStatus: "ready", PrimaryDomain: "due.example.com", NotAfter: now.Add(3 * 24 * time.Hour)},
{ID: 2, Provider: "acme", AutoRenew: true, ApplyStatus: "ready", PrimaryDomain: "fresh.example.com", NotAfter: now.Add(30 * 24 * time.Hour)},
{ID: 3, Provider: "upload", AutoRenew: true, ApplyStatus: "ready", PrimaryDomain: "upload.example.com", NotAfter: now.Add(24 * time.Hour)},
{ID: 4, Provider: "acme", AutoRenew: false, ApplyStatus: "ready", PrimaryDomain: "manual.example.com", NotAfter: now.Add(24 * time.Hour)},
{ID: 5, Provider: "acme", AutoRenew: true, ApplyStatus: "applying", PrimaryDomain: "busy.example.com", NotAfter: now.Add(24 * time.Hour)},
}
due := CertificatesDueForRenewal(certificates, now)
require.Len(t, due, 1)
assert.Equal(t, uint(1), due[0].ID)
assert.Equal(t, "due.example.com", due[0].PrimaryDomain)
}
func TestApplyCertificateReturnsApplying(t *testing.T) {
cleanup := setupTLSTestDB(t)
defer cleanup()
ctx := context.Background()
dnsAccount, err := CreateDNSAccount(ctx, DNSAccountInput{
Name: "Test Cloudflare",
Type: "cloudflare",
Authorization: `{"api_token": "dummy_token"}`,
})
require.NoError(t, err)
restore := SetObtainCertificateFuncForTest(func(ctx context.Context, cert *model.TLSCertificate) error {
return updateCertError(ctx, cert, "dns challenge failed")
})
defer restore()
cert, err := ApplyCertificate(ctx, ApplyInput{
Name: "Test ACME Cert",
PrimaryDomain: "example.com",
OtherDomains: "*.example.com",
DnsAccountID: dnsAccount.ID,
KeyAlgorithm: "RSA2048",
AutoRenew: true,
})
require.NoError(t, err)
assert.Equal(t, "applying", cert.ApplyStatus)
assert.Equal(t, "acme", cert.Provider)
}
func TestRenewCertificateSetsApplying(t *testing.T) {
cleanup := setupTLSTestDB(t)
defer cleanup()
ctx := context.Background()
cert := &model.TLSCertificate{
Name: "renew-cert",
Provider: "acme",
AutoRenew: true,
ApplyStatus: "ready",
PrimaryDomain: "renew.example.com",
CertPEM: " ",
KeyPEM: " ",
}
require.NoError(t, model.CreateTLSCertificateRecord(ctx, cert))
restore := SetObtainCertificateFuncForTest(func(ctx context.Context, c *model.TLSCertificate) error {
return nil
})
defer restore()
renewed, err := RenewCertificate(ctx, cert.ID)
require.NoError(t, err)
assert.Equal(t, "applying", renewed.ApplyStatus)
}
func TestConvertCertificateToACMEPreservesUploadOnFailure(t *testing.T) {
cleanup := setupTLSTestDB(t)
defer cleanup()
ctx := context.Background()
originalCertPEM, originalKeyPEM := generateTestCertificatePair(t, []string{"manual.example.com"})
cert, err := CreateCertificate(ctx, CertificateInput{
Name: "manual-cert",
CertPEM: originalCertPEM,
KeyPEM: originalKeyPEM,
})
require.NoError(t, err)
stored, err := model.GetTLSCertificateByID(ctx, cert.ID)
require.NoError(t, err)
originalStoredCertPEM := stored.CertPEM
originalStoredKeyPEM := stored.KeyPEM
stored.ApplyStatus = "applying"
stored.PrimaryDomain = "manual.example.com"
require.NoError(t, model.SaveTLSCertificate(ctx, stored))
err = updateCertError(ctx, stored, "dns challenge failed")
require.Error(t, err)
finalCert, err := model.GetTLSCertificateByID(ctx, cert.ID)
require.NoError(t, err)
assert.Equal(t, "upload", finalCert.Provider)
assert.Equal(t, "error", finalCert.ApplyStatus)
assert.Equal(t, originalStoredCertPEM, finalCert.CertPEM)
assert.Equal(t, originalStoredKeyPEM, finalCert.KeyPEM)
assert.True(t, strings.Contains(finalCert.ApplyMessage, "dns challenge failed"))
}
func TestConvertCertificateToACMERejectsInvalidStates(t *testing.T) {
cleanup := setupTLSTestDB(t)
defer cleanup()
ctx := context.Background()
certPEM, keyPEM := generateTestCertificatePair(t, []string{"manual.example.com"})
cert, err := CreateCertificate(ctx, CertificateInput{
Name: "manual-cert",
CertPEM: certPEM,
KeyPEM: keyPEM,
})
require.NoError(t, err)
cert.Provider = "acme"
require.NoError(t, model.SaveTLSCertificate(ctx, cert))
_, err = ConvertCertificateToACME(ctx, cert.ID, ApplyInput{Name: "manual-cert"})
require.Error(t, err)
assert.Contains(t, err.Error(), "only uploaded")
cert.Provider = "upload"
cert.ApplyStatus = "applying"
require.NoError(t, model.SaveTLSCertificate(ctx, cert))
_, err = ConvertCertificateToACME(ctx, cert.ID, ApplyInput{Name: "manual-cert"})
require.Error(t, err)
assert.Contains(t, err.Error(), "already applying")
}
+26
View File
@@ -0,0 +1,26 @@
// 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 账号已被证书使用,无法删除"
)
+62
View File
@@ -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
}
+482
View File
@@ -0,0 +1,482 @@
// 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
}
go func(c *model.TLSCertificate) {
_ = obtainTLSCertificate(context.Background(), c)
}(cert)
return sanitizeCertificateForResponse(cert), nil
}
// 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
}
go func(c *model.TLSCertificate) {
_ = obtainTLSCertificate(context.Background(), c)
}(cert)
return sanitizeCertificateForResponse(cert), nil
}
// 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
}
go func(c *model.TLSCertificate) {
if err := obtainTLSCertificate(context.Background(), c); err != nil {
return
}
latest, err := model.GetTLSCertificateByID(context.Background(), c.ID)
if err != nil {
return
}
latest.Provider = "acme"
latest.ApplyStatus = "ready"
latest.ApplyMessage = ""
_ = model.SaveTLSCertificate(context.Background(), latest)
}(cert)
return sanitizeCertificateForResponse(cert), nil
}
// 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)
}
go func(c *model.TLSCertificate) {
_ = obtainTLSCertificate(context.Background(), c)
}(cert)
cert.ApplyStatus = "applying"
cert.ApplyMessage = ""
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
return nil, err
}
return sanitizeCertificateForResponse(cert), nil
}
// 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.ApplyStatus = "applying"
}
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 &copy
}
func sanitizeDNSAccountForResponse(account *model.DNSAccount) *model.DNSAccount {
if account == nil {
return nil
}
copy := *account
copy.Authorization = ""
return &copy
}
func sanitizeAcmeAccountForResponse(account *model.AcmeAccount) *model.AcmeAccount {
if account == nil {
return nil
}
copy := *account
copy.PrivateKey = ""
return &copy
}
+146
View File
@@ -0,0 +1,146 @@
// 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"
"sync"
"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"
)
var tlsTestDBMu sync.Mutex
func setupTLSTestDB(t *testing.T) func() {
t.Helper()
tlsTestDBMu.Lock()
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
tlsTestDBMu.Unlock()
}
}
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
})
}
+169
View File
@@ -0,0 +1,169 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tls
import (
"context"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls/acme"
"github.com/Rain-kl/Wavelet/internal/model"
)
const acmeRenewLeadTime = 7 * 24 * time.Hour
var obtainTLSCertificate = obtainCertificate
// SetObtainCertificateFuncForTest swaps the async obtain implementation for tests.
func SetObtainCertificateFuncForTest(fn func(context.Context, *model.TLSCertificate) error) func() {
previous := obtainTLSCertificate
obtainTLSCertificate = fn
return func() {
obtainTLSCertificate = previous
}
}
func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
cert.ApplyStatus = "applying"
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
return err
}
acmeAccount, err := model.GetAcmeAccountByID(ctx, cert.AcmeAccountID)
if err != nil {
acmeAccount, err = model.GetDefaultAcmeAccount(ctx)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get ACME account: %v", err))
}
cert.AcmeAccountID = acmeAccount.ID
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
return err
}
}
dnsAccount, err := model.GetDNSAccountByID(ctx, cert.DnsAccountID)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get DNS account: %v", err))
}
dnsAuth, err := openSensitive(dnsAccount.Authorization)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt DNS credentials: %v", err))
}
acmePrivateKey, err := openSensitive(acmeAccount.PrivateKey)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt ACME account key: %v", err))
}
domains := splitAcmeDomains(cert.PrimaryDomain, cert.OtherDomains)
newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL(
acmeAccount.Email,
acmePrivateKey,
acmeAccount.URL,
dnsAccount.Type,
dnsAuth,
cert.DNS1,
cert.DNS2,
cert.DisableCNAME,
cert.SkipDNS,
cert.KeyAlgorithm,
domains,
)
if (newPrivateKeyPEM != "" && acmePrivateKey != newPrivateKeyPEM) || (newAccountURL != "" && acmeAccount.URL != newAccountURL) {
if newPrivateKeyPEM != "" {
sealedKey, sealErr := sealSensitive(newPrivateKeyPEM)
if sealErr != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to seal ACME account key: %v", sealErr))
}
acmeAccount.PrivateKey = sealedKey
}
if newAccountURL != "" {
acmeAccount.URL = newAccountURL
}
if acmeAccount.ID == 0 {
if dbErr := model.CreateAcmeAccountRecord(ctx, acmeAccount); dbErr != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to create ACME account: %v", dbErr))
}
} else if dbErr := model.SaveAcmeAccount(ctx, acmeAccount); dbErr != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to save ACME account: %v", dbErr))
}
cert.AcmeAccountID = acmeAccount.ID
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
return err
}
}
if err != nil {
return updateCertError(ctx, cert, err.Error())
}
sealedKey, err := sealSensitive(result.KeyPEM)
if err != nil {
return updateCertError(ctx, cert, fmt.Sprintf("Failed to seal certificate key: %v", err))
}
cert.CertPEM = result.CertPEM
cert.KeyPEM = sealedKey
cert.NotBefore = result.NotBefore
cert.NotAfter = result.NotAfter
cert.ApplyStatus = "ready"
cert.ApplyMessage = ""
return model.SaveTLSCertificate(ctx, cert)
}
func updateCertError(ctx context.Context, cert *model.TLSCertificate, message string) error {
cert.ApplyStatus = "error"
cert.ApplyMessage = message
if err := model.SaveTLSCertificate(ctx, cert); err != nil {
return err
}
return fmt.Errorf("%s", message)
}
func splitAcmeDomains(primaryDomain, otherDomains string) []string {
primaryDomain = strings.TrimSpace(primaryDomain)
domains := []string{}
if primaryDomain != "" {
domains = append(domains, primaryDomain)
}
otherDomains = strings.TrimSpace(otherDomains)
if otherDomains == "" {
return domains
}
separator := "\n"
if !strings.Contains(otherDomains, "\n") && strings.Contains(otherDomains, ",") {
separator = ","
}
for _, domain := range strings.Split(otherDomains, separator) {
domain = strings.TrimSpace(domain)
if domain != "" {
domains = append(domains, domain)
}
}
return domains
}
// CertificatesDueForRenewal returns ACME certificates that should be renewed at the given time.
func CertificatesDueForRenewal(certificates []model.TLSCertificate, now time.Time) []model.TLSCertificate {
due := make([]model.TLSCertificate, 0)
for _, cert := range certificates {
if !cert.AutoRenew || cert.Provider != "acme" || cert.ApplyStatus == "applying" {
continue
}
if cert.NotAfter.IsZero() {
continue
}
if cert.NotAfter.Sub(now) < acmeRenewLeadTime {
due = append(due, cert)
}
}
return due
}
+578
View File
@@ -0,0 +1,578 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package tls
import (
"net/http"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
}
// GetCertificates 列出 TLS 证书。
// @Summary 列出 TLS 证书
// @Description 返回全部 TLS 证书(不含 PEM),需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.TLSCertificate} "证书列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates [get]
func GetCertificates(c *gin.Context) {
certificates, err := ListCertificates(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificates))
}
// GetCertificateDetail 获取 TLS 证书详情。
// @Summary 获取 TLS 证书详情
// @Description 按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "证书详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/{id} [get]
func GetCertificateDetail(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
certificate, err := GetCertificate(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// GetCertificateContentHandler 获取 TLS 证书 PEM 内容。
// @Summary 获取 TLS 证书 PEM 内容
// @Description 按 ID 返回证书与私钥 PEM 内容,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any{data=tls.CertificateContent} "证书 PEM 内容"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/{id}/content [get]
func GetCertificateContentHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
content, err := GetCertificateContent(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(content))
}
// CreateCertificateHandler 从 PEM 创建证书。
// @Summary 创建 TLS 证书
// @Description 从 PEM 文本创建 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.CertificateInput true "证书参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "创建成功的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates [post]
func CreateCertificateHandler(c *gin.Context) {
var input CertificateInput
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := CreateCertificate(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// UpdateCertificateHandler 更新证书。
// @Summary 更新 TLS 证书
// @Description 按 ID 更新 TLS 证书 PEM 信息,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Param request body tls.CertificateInput true "证书参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "更新后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/{id}/update [post]
func UpdateCertificateHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input CertificateInput
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := UpdateCertificate(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// ImportCertificateFile 从文件导入证书。
// @Summary 从文件导入 TLS 证书
// @Description 上传证书与私钥文件创建 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Accept multipart/form-data
// @Produce json
// @Security SessionCookie
// @Param name formData string false "证书名称"
// @Param remark formData string false "备注"
// @Param cert_file formData file true "证书文件"
// @Param key_file formData file true "私钥文件"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "导入成功的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/import-file [post]
func ImportCertificateFile(c *gin.Context) {
name := c.PostForm("name")
remark := c.PostForm("remark")
certFile, err := c.FormFile("cert_file")
if err != nil {
response.AbortBadRequest(c, "缺少证书文件")
return
}
keyFile, err := c.FormFile("key_file")
if err != nil {
response.AbortBadRequest(c, "缺少私钥文件")
return
}
certificate, err := CreateCertificateFromFiles(c.Request.Context(), name, certFile, keyFile, remark)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// DeleteCertificateHandler 删除证书。
// @Summary 删除 TLS 证书
// @Description 按 ID 删除 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/{id}/delete [post]
func DeleteCertificateHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteCertificate(c.Request.Context(), id); handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ApplyCertificateHandler 申请 ACME 证书。
// @Summary 申请 ACME 证书
// @Description 通过 ACME 申请新的 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.ApplyInput true "ACME 申请参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "申请中的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/apply [post]
func ApplyCertificateHandler(c *gin.Context) {
var input ApplyInput
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := ApplyCertificate(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// UpdateACMECertificateHandler 更新 ACME 证书配置。
// @Summary 更新 ACME 证书配置
// @Description 按 ID 更新 ACME 证书申请配置,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Param request body tls.ApplyInput true "ACME 申请参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "更新后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/{id}/update-acme [post]
func UpdateACMECertificateHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input ApplyInput
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := UpdateACMECertificate(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// ConvertCertificateToACMEHandler 将上传证书转为 ACME。
// @Summary 将证书转为 ACME 管理
// @Description 将已上传证书转换为 ACME 自动续期模式,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Param request body tls.ApplyInput true "ACME 申请参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "转换后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/{id}/convert-acme [post]
func ConvertCertificateToACMEHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input ApplyInput
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := ConvertCertificateToACME(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// RenewCertificateHandler 续期 ACME 证书。
// @Summary 续期 ACME 证书
// @Description 手动触发 ACME 证书续期,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "续期后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/tls-certificates/{id}/renew [post]
func RenewCertificateHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
certificate, err := RenewCertificate(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(certificate))
}
// GetManagedDomains 列出托管域名。
// @Summary 列出托管域名
// @Description 返回全部托管域名及关联证书,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.ManagedDomain} "托管域名列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains [get]
func GetManagedDomains(c *gin.Context) {
domains, err := ListManagedDomains(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(domains))
}
// CreateManagedDomainHandler 创建托管域名。
// @Summary 创建托管域名
// @Description 创建新的托管域名记录,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.ManagedDomainInput true "托管域名参数"
// @Success 200 {object} response.Any{data=model.ManagedDomain} "创建成功的托管域名"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains [post]
func CreateManagedDomainHandler(c *gin.Context) {
var input ManagedDomainInput
if !apiutil.BindJSON(c, &input) {
return
}
domain, err := CreateManagedDomain(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(domain))
}
// UpdateManagedDomainHandler 更新托管域名。
// @Summary 更新托管域名
// @Description 按 ID 更新托管域名,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "托管域名 ID"
// @Param request body tls.ManagedDomainInput true "托管域名参数"
// @Success 200 {object} response.Any{data=model.ManagedDomain} "更新后的托管域名"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains/{id}/update [post]
func UpdateManagedDomainHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input ManagedDomainInput
if !apiutil.BindJSON(c, &input) {
return
}
domain, err := UpdateManagedDomain(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(domain))
}
// DeleteManagedDomainHandler 删除托管域名。
// @Summary 删除托管域名
// @Description 按 ID 删除托管域名,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "托管域名 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains/{id}/delete [post]
func DeleteManagedDomainHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteManagedDomain(c.Request.Context(), id); handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// MatchManagedDomainCertificateHandler 匹配域名证书。
// @Summary 匹配托管域名证书
// @Description 按域名查询可用的证书匹配候选,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param domain query string true "域名"
// @Success 200 {object} response.Any{data=tls.ManagedDomainMatchResult} "证书匹配结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains/match [get]
func MatchManagedDomainCertificateHandler(c *gin.Context) {
domain := strings.TrimSpace(c.Query("domain"))
result, err := MatchManagedDomainCertificate(c.Request.Context(), domain)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
}
// GetDNSAccounts 列出 DNS 账号。
// @Summary 列出 DNS 账号
// @Description 返回全部 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.DNSAccount} "DNS 账号列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/dns-accounts [get]
func GetDNSAccounts(c *gin.Context) {
accounts, err := ListDNSAccounts(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(accounts))
}
// CreateDNSAccountHandler 创建 DNS 账号。
// @Summary 创建 DNS 账号
// @Description 创建新的 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.DNSAccountInput true "DNS 账号参数"
// @Success 200 {object} response.Any{data=model.DNSAccount} "创建成功的 DNS 账号"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/dns-accounts [post]
func CreateDNSAccountHandler(c *gin.Context) {
var input DNSAccountInput
if !apiutil.BindJSON(c, &input) {
return
}
account, err := CreateDNSAccount(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(account))
}
// UpdateDNSAccountHandler 更新 DNS 账号。
// @Summary 更新 DNS 账号
// @Description 按 ID 更新 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "DNS 账号 ID"
// @Param request body tls.DNSAccountInput true "DNS 账号参数"
// @Success 200 {object} response.Any{data=model.DNSAccount} "更新后的 DNS 账号"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/dns-accounts/{id}/update [post]
func UpdateDNSAccountHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input DNSAccountInput
if !apiutil.BindJSON(c, &input) {
return
}
account, err := UpdateDNSAccount(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(account))
}
// DeleteDNSAccountHandler 删除 DNS 账号。
// @Summary 删除 DNS 账号
// @Description 按 ID 删除 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "DNS 账号 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/dns-accounts/{id}/delete [post]
func DeleteDNSAccountHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteDNSAccount(c.Request.Context(), id); handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// GetDefaultAcmeAccountHandler 获取默认 ACME 账号。
// @Summary 获取默认 ACME 账号
// @Description 返回系统默认 ACME 账号配置,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.AcmeAccount} "默认 ACME 账号"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/acme-accounts/default [get]
func GetDefaultAcmeAccountHandler(c *gin.Context) {
account, err := GetDefaultAcmeAccount(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(account))
}
+55
View File
@@ -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))
}