feat: acme accounts

This commit is contained in:
Yoan.Liu
2026-08-07 14:20:38 +08:00
parent 52a61a9141
commit 696e73a6bb
16 changed files with 1314 additions and 1 deletions
+119
View File
@@ -0,0 +1,119 @@
package acmeaccount
import (
"context"
"fmt"
"github.com/certimate-go/certimate/internal/certacme"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/domain/dtos"
)
type acmeAccountRepository interface {
List(ctx context.Context, page, perPage int, ca string) ([]*domain.ACMEAccount, int64, error)
GetById(ctx context.Context, id string) (*domain.ACMEAccount, error)
}
type Service struct {
repo acmeAccountRepository
}
func NewService(repo acmeAccountRepository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(ctx context.Context, req *dtos.ACMEAccountListReq) (*dtos.ACMEAccountListResp, error) {
page := 1
perPage := 15
ca := ""
if req != nil {
page = req.Page
perPage = req.PerPage
ca = req.CA
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 15
}
if perPage > 100 {
perPage = 100
}
accounts, total, err := s.repo.List(ctx, page, perPage, ca)
if err != nil {
return nil, fmt.Errorf("failed to list acme accounts: %w", err)
}
items := make([]*dtos.ACMEAccountView, 0, len(accounts))
for _, account := range accounts {
items = append(items, toView(account))
}
return &dtos.ACMEAccountListResp{
Items: items,
TotalItems: total,
Page: page,
PerPage: perPage,
}, nil
}
func (s *Service) Import(ctx context.Context, req *dtos.ACMEAccountImportReq) (*dtos.ACMEAccountImportResp, error) {
if req == nil {
return nil, domain.NewError(400, "request body is empty")
}
account, err := certacme.ImportACMEAccount(ctx, &certacme.ImportACMEAccountOptions{
CA: req.CA,
ACMEDirUrl: req.ACMEDirUrl,
PrivateKeyPem: req.PrivateKeyPem,
Email: req.Email,
})
if err != nil {
return nil, err
}
return &dtos.ACMEAccountImportResp{Item: toView(account)}, nil
}
func (s *Service) Export(ctx context.Context, accountId string) (*dtos.ACMEAccountExportResp, error) {
if accountId == "" {
return nil, domain.NewError(400, "the account id is empty")
}
account, err := s.repo.GetById(ctx, accountId)
if err != nil {
if domain.IsRecordNotFoundError(err) {
return nil, domain.ErrRecordNotFound
}
return nil, fmt.Errorf("failed to get acme account: %w", err)
}
if account.PrivateKey == "" {
return nil, domain.NewError(404, "private key is empty")
}
return &dtos.ACMEAccountExportResp{PrivateKeyPem: account.PrivateKey}, nil
}
func (s *Service) Rotate(ctx context.Context, accountId string) (*dtos.ACMEAccountRotateResp, error) {
account, err := certacme.RotateACMEAccountKey(ctx, accountId)
if err != nil {
return nil, err
}
return &dtos.ACMEAccountRotateResp{Item: toView(account)}, nil
}
func toView(account *domain.ACMEAccount) *dtos.ACMEAccountView {
if account == nil {
return nil
}
return &dtos.ACMEAccountView{
Id: account.Id,
CA: account.CA,
Email: account.Email,
ACMEDirectoryUrl: account.ACMEDirectoryUrl,
ACMEAccountUrl: account.ACMEAccountUrl,
CreatedAt: account.CreatedAt,
UpdatedAt: account.UpdatedAt,
}
}
+160
View File
@@ -0,0 +1,160 @@
package acmeaccount
import (
"context"
"testing"
"time"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/domain/dtos"
)
type mockRepo struct {
accounts []*domain.ACMEAccount
byID map[string]*domain.ACMEAccount
page int
perPage int
ca string
}
func (m *mockRepo) List(ctx context.Context, page, perPage int, ca string) ([]*domain.ACMEAccount, int64, error) {
m.page = page
m.perPage = perPage
m.ca = ca
filtered := make([]*domain.ACMEAccount, 0)
for _, a := range m.accounts {
if ca != "" && a.CA != ca {
continue
}
filtered = append(filtered, a)
}
total := int64(len(filtered))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 15
}
start := (page - 1) * perPage
if start >= len(filtered) {
return []*domain.ACMEAccount{}, total, nil
}
end := start + perPage
if end > len(filtered) {
end = len(filtered)
}
return filtered[start:end], total, nil
}
func (m *mockRepo) GetById(ctx context.Context, id string) (*domain.ACMEAccount, error) {
if a, ok := m.byID[id]; ok {
return a, nil
}
return nil, domain.ErrRecordNotFound
}
func TestList_OmitsPrivateKeyAndPaginates(t *testing.T) {
repo := &mockRepo{
accounts: []*domain.ACMEAccount{
{
Meta: domain.Meta{Id: "a1", CreatedAt: time.Now(), UpdatedAt: time.Now()},
CA: "letsencrypt",
Email: "a@example.com",
PrivateKey: "-----BEGIN EC PRIVATE KEY-----\nSECRET\n-----END EC PRIVATE KEY-----",
ACMEDirectoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
ACMEAccountUrl: "https://acme-v02.api.letsencrypt.org/acme/acct/1",
},
{
Meta: domain.Meta{Id: "a2", CreatedAt: time.Now(), UpdatedAt: time.Now()},
CA: "zerossl",
Email: "b@example.com",
PrivateKey: "SECRET2",
ACMEDirectoryUrl: "https://acme.zerossl.com/v2/DV90",
ACMEAccountUrl: "https://acme.zerossl.com/v2/DV90/account/2",
},
},
}
svc := NewService(repo)
resp, err := svc.List(context.Background(), &dtos.ACMEAccountListReq{Page: 1, PerPage: 1})
if err != nil {
t.Fatal(err)
}
if len(resp.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(resp.Items))
}
if resp.TotalItems != 2 {
t.Fatalf("totalItems = %d", resp.TotalItems)
}
if resp.Page != 1 || resp.PerPage != 1 {
t.Fatalf("page/perPage = %d/%d", resp.Page, resp.PerPage)
}
if resp.Items[0].Email != "a@example.com" {
t.Fatalf("email = %q", resp.Items[0].Email)
}
if resp.Items[0].Id != "a1" || resp.Items[0].ACMEAccountUrl == "" {
t.Fatalf("unexpected view: %+v", resp.Items[0])
}
}
func TestList_FilterByCA(t *testing.T) {
repo := &mockRepo{
accounts: []*domain.ACMEAccount{
{Meta: domain.Meta{Id: "a1"}, CA: "letsencrypt", Email: "a@example.com"},
{Meta: domain.Meta{Id: "a2"}, CA: "zerossl", Email: "b@example.com"},
},
}
svc := NewService(repo)
resp, err := svc.List(context.Background(), &dtos.ACMEAccountListReq{Page: 1, PerPage: 10, CA: "zerossl"})
if err != nil {
t.Fatal(err)
}
if len(resp.Items) != 1 || resp.TotalItems != 1 || resp.Items[0].CA != "zerossl" {
t.Fatalf("unexpected: %+v total=%d", resp.Items, resp.TotalItems)
}
if repo.ca != "zerossl" {
t.Fatalf("repo ca = %q", repo.ca)
}
}
func TestExport_ReturnsPEM(t *testing.T) {
pem := "-----BEGIN EC PRIVATE KEY-----\nTEST\n-----END EC PRIVATE KEY-----"
repo := &mockRepo{
byID: map[string]*domain.ACMEAccount{
"a1": {
Meta: domain.Meta{Id: "a1"},
PrivateKey: pem,
},
},
}
svc := NewService(repo)
resp, err := svc.Export(context.Background(), "a1")
if err != nil {
t.Fatal(err)
}
if resp.PrivateKeyPem != pem {
t.Fatalf("pem mismatch")
}
}
func TestExport_NotFound(t *testing.T) {
svc := NewService(&mockRepo{byID: map[string]*domain.ACMEAccount{}})
_, err := svc.Export(context.Background(), "missing")
if !domain.IsRecordNotFoundError(err) {
t.Fatalf("expected not found, got %v", err)
}
}
func TestToView_Nil(t *testing.T) {
if toView(nil) != nil {
t.Fatal("expected nil")
}
}
func TestImport_NilRequest(t *testing.T) {
svc := NewService(&mockRepo{})
_, err := svc.Import(context.Background(), nil)
if err == nil {
t.Fatal("expected error")
}
_ = &dtos.ACMEAccountImportReq{}
}
+173
View File
@@ -0,0 +1,173 @@
package certacme
import (
"context"
"crypto"
"fmt"
"strings"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/lego"
"github.com/certimate-go/certimate/internal/app"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/repository"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
type ImportACMEAccountOptions struct {
CA string
ACMEDirUrl string
PrivateKeyPem string
Email string
}
func ImportACMEAccount(ctx context.Context, opts *ImportACMEAccountOptions) (*ACMEAccount, error) {
if opts == nil {
return nil, domain.NewError(400, "the import options are nil")
}
if strings.TrimSpace(opts.CA) == "" {
return nil, domain.NewError(400, "the ca is empty")
}
if strings.TrimSpace(opts.PrivateKeyPem) == "" {
return nil, domain.NewError(400, "the private key is empty")
}
dirUrl, err := resolveImportDirectoryURL(opts.CA, opts.ACMEDirUrl)
if err != nil {
return nil, err
}
privKey, err := xcert.ParsePrivateKeyFromPEM(opts.PrivateKeyPem)
if err != nil {
return nil, domain.NewError(400, fmt.Sprintf("invalid private key pem: %v", err))
}
signer, ok := privKey.(crypto.Signer)
if !ok {
return nil, domain.NewError(400, "private key is not a crypto.Signer")
}
keyPEM, err := xcert.ConvertPrivateKeyToPEM(privKey, false)
if err != nil {
keyPEM = opts.PrivateKeyPem
}
placeholder := &acme.Account{}
tempEmail := strings.TrimSpace(opts.Email)
if tempEmail == "" {
tempEmail = "import@local"
}
legoUser := &importLegoUser{
email: tempEmail,
privateKey: signer,
registration: &acme.ExtendedAccount{
Account: *placeholder,
Location: "",
},
}
legoCfg := lego.NewConfig(legoUser)
legoCfg.UserAgent = app.AppUserAgent
legoCfg.CADirURL = dirUrl
legoClient, err := lego.NewClient(legoCfg)
if err != nil {
return nil, fmt.Errorf("failed to create lego client: %w", err)
}
resolved, err := legoClient.Registration.ResolveAccountByKey(ctx)
if err != nil {
return nil, domain.NewError(404, fmt.Sprintf("failed to resolve acme account by key: %v", err))
}
if resolved == nil || resolved.Location == "" {
return nil, domain.NewError(404, "acme account not found for the given private key")
}
email := extractEmailFromContacts(resolved.Contact)
if email == "" {
email = strings.TrimSpace(opts.Email)
}
if email == "" {
return nil, domain.NewError(400, "the account has no email contact; please provide an email")
}
accountRepo := repository.NewACMEAccountRepository()
existing, err := accountRepo.GetByCAAndAcctUrl(ctx, opts.CA, resolved.Location)
if err != nil && !domain.IsRecordNotFoundError(err) {
return nil, fmt.Errorf("failed to check existing acme account: %w", err)
}
if existing != nil {
return nil, domain.NewError(409, "acme account already exists")
}
if byEmail, err := accountRepo.GetByCAAndEmail(ctx, opts.CA, dirUrl, email); err == nil && byEmail != nil {
if byEmail.ACMEAccountUrl == resolved.Location {
return nil, domain.NewError(409, "acme account already exists")
}
} else if err != nil && !domain.IsRecordNotFoundError(err) {
return nil, fmt.Errorf("failed to check existing acme account by email: %w", err)
}
account := &ACMEAccount{
CA: opts.CA,
Email: email,
PrivateKey: keyPEM,
ACMEDirectoryUrl: dirUrl,
ACMEAccountUrl: resolved.Location,
ResourceObject: &resolved.Account,
}
if _, err := accountRepo.Save(ctx, account); err != nil {
return nil, fmt.Errorf("failed to save acme account record: %w", err)
}
return account, nil
}
func resolveImportDirectoryURL(ca, explicitDir string) (string, error) {
if strings.TrimSpace(explicitDir) != "" {
return strings.TrimSpace(explicitDir), nil
}
provider := domain.CAProviderType(ca)
if provider == domain.CAProviderTypeACMECA {
return "", domain.NewError(400, "acmeDirUrl is required for custom ACME CA")
}
dirUrl, err := getCADirUrl(provider, nil, "")
if err != nil {
return "", domain.NewError(400, err.Error())
}
return dirUrl, nil
}
func extractEmailFromContacts(contacts []string) string {
for _, c := range contacts {
c = strings.TrimSpace(c)
if strings.HasPrefix(strings.ToLower(c), "mailto:") {
email := strings.TrimSpace(c[len("mailto:"):])
if email != "" {
return email
}
}
}
return ""
}
type importLegoUser struct {
email string
privateKey crypto.Signer
registration *acme.ExtendedAccount
}
func (u *importLegoUser) GetEmail() string {
return u.email
}
func (u *importLegoUser) GetRegistration() *acme.ExtendedAccount {
return u.registration
}
func (u *importLegoUser) GetPrivateKey() crypto.Signer {
return u.privateKey
}
+48
View File
@@ -0,0 +1,48 @@
package certacme
import "testing"
func TestExtractEmailFromContacts(t *testing.T) {
tests := []struct {
name string
contacts []string
want string
}{
{"empty", nil, ""},
{"mailto", []string{"mailto:user@example.com"}, "user@example.com"},
{"mixed", []string{"tel:+1", "mailto:u@x.com"}, "u@x.com"},
{"whitespace", []string{" mailto: spaced@x.com "}, "spaced@x.com"},
{"no mailto", []string{"user@example.com"}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractEmailFromContacts(tt.contacts)
if got != tt.want {
t.Fatalf("got %q want %q", got, tt.want)
}
})
}
}
func TestResolveImportDirectoryURL(t *testing.T) {
dir, err := resolveImportDirectoryURL("letsencrypt", "")
if err != nil {
t.Fatal(err)
}
if dir != "https://acme-v02.api.letsencrypt.org/directory" {
t.Fatalf("dir = %q", dir)
}
dir, err = resolveImportDirectoryURL("letsencrypt", "https://custom.example/directory")
if err != nil {
t.Fatal(err)
}
if dir != "https://custom.example/directory" {
t.Fatalf("override dir = %q", dir)
}
_, err = resolveImportDirectoryURL("acmeca", "")
if err == nil {
t.Fatal("expected error for acmeca without dir")
}
}
+91
View File
@@ -0,0 +1,91 @@
package certacme
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"fmt"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/lego"
"github.com/certimate-go/certimate/internal/app"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/internal/repository"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
)
func RotateACMEAccountKey(ctx context.Context, accountId string) (*ACMEAccount, error) {
if accountId == "" {
return nil, domain.NewError(400, "the account id is empty")
}
accountRepo := repository.NewACMEAccountRepository()
account, err := accountRepo.GetById(ctx, accountId)
if err != nil {
if domain.IsRecordNotFoundError(err) {
return nil, domain.ErrRecordNotFound
}
return nil, fmt.Errorf("failed to get acme account: %w", err)
}
currentKey := account.GetPrivateKey()
if currentKey == nil {
return nil, domain.NewError(400, "stored private key is invalid or empty")
}
newKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate new account key: %w", err)
}
newKeyPEM, err := xcert.ConvertECPrivateKeyToPEM(newKey, false)
if err != nil {
return nil, fmt.Errorf("failed to encode new account key: %w", err)
}
reg := account.GetRegistration()
if reg == nil || reg.Location == "" {
return nil, domain.NewError(400, "account registration location is empty")
}
legoUser := &importLegoUser{
email: account.Email,
privateKey: currentKey,
registration: reg,
}
legoCfg := lego.NewConfig(legoUser)
legoCfg.UserAgent = app.AppUserAgent
legoCfg.CADirURL = account.ACMEDirectoryUrl
legoClient, err := lego.NewClient(legoCfg)
if err != nil {
return nil, fmt.Errorf("failed to create lego client: %w", err)
}
if err := legoClient.Registration.KeyRollover(ctx, newKey); err != nil {
return nil, fmt.Errorf("failed to rollover acme account key: %w", err)
}
legoUser.privateKey = newKey
legoUser.registration = &acme.ExtendedAccount{
Account: *account.ResourceObject,
Location: account.ACMEAccountUrl,
}
legoCfg2 := lego.NewConfig(legoUser)
legoCfg2.UserAgent = app.AppUserAgent
legoCfg2.CADirURL = account.ACMEDirectoryUrl
legoClient2, err := lego.NewClient(legoCfg2)
if err == nil {
if refreshed, qerr := legoClient2.Registration.QueryRegistration(ctx); qerr == nil && refreshed != nil {
account.ResourceObject = &refreshed.Account
}
}
account.PrivateKey = newKeyPEM
if _, err := accountRepo.Save(ctx, account); err != nil {
return nil, fmt.Errorf("failed to save rotated acme account: %w", err)
}
return account, nil
}
+45
View File
@@ -0,0 +1,45 @@
package dtos
import "time"
type ACMEAccountView struct {
Id string `json:"id"`
CA string `json:"ca"`
Email string `json:"email"`
ACMEDirectoryUrl string `json:"acmeDirUrl"`
ACMEAccountUrl string `json:"acmeAcctUrl"`
CreatedAt time.Time `json:"created"`
UpdatedAt time.Time `json:"updated"`
}
type ACMEAccountListReq struct {
Page int `json:"-"`
PerPage int `json:"-"`
CA string `json:"-"`
}
type ACMEAccountListResp struct {
Items []*ACMEAccountView `json:"items"`
TotalItems int64 `json:"totalItems"`
Page int `json:"page"`
PerPage int `json:"perPage"`
}
type ACMEAccountImportReq struct {
PrivateKeyPem string `json:"privateKeyPem"`
CA string `json:"ca"`
ACMEDirUrl string `json:"acmeDirUrl,omitempty"`
Email string `json:"email,omitempty"`
}
type ACMEAccountImportResp struct {
Item *ACMEAccountView `json:"item"`
}
type ACMEAccountExportResp struct {
PrivateKeyPem string `json:"privateKeyPem"`
}
type ACMEAccountRotateResp struct {
Item *ACMEAccountView `json:"item"`
}
+61
View File
@@ -52,6 +52,67 @@ func (r *ACMEAccountRepository) GetByCAAndAcctUrl(ctx context.Context, ca string
return r.castRecordToModel(record)
}
func (r *ACMEAccountRepository) GetById(ctx context.Context, id string) (*domain.ACMEAccount, error) {
record, err := app.GetApp().FindRecordById(domain.CollectionNameACMEAccount, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
return r.castRecordToModel(record)
}
func (r *ACMEAccountRepository) List(ctx context.Context, page, perPage int, ca string) ([]*domain.ACMEAccount, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 15
}
if perPage > 100 {
perPage = 100
}
filter := ""
params := dbx.Params{}
var countExprs []dbx.Expression
if ca != "" {
filter = "ca={:ca}"
params["ca"] = ca
countExprs = append(countExprs, dbx.HashExp{"ca": ca})
}
total, err := app.GetApp().CountRecords(domain.CollectionNameACMEAccount, countExprs...)
if err != nil {
return nil, 0, err
}
offset := (page - 1) * perPage
records, err := app.GetApp().FindRecordsByFilter(
domain.CollectionNameACMEAccount,
filter,
"ca,created",
perPage,
offset,
params,
)
if err != nil {
return nil, 0, err
}
accounts := make([]*domain.ACMEAccount, 0, len(records))
for _, record := range records {
account, err := r.castRecordToModel(record)
if err != nil {
return nil, 0, err
}
accounts = append(accounts, account)
}
return accounts, total, nil
}
func (r *ACMEAccountRepository) Save(ctx context.Context, acmeAccount *domain.ACMEAccount) (*domain.ACMEAccount, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameACMEAccount)
if err != nil {
+80
View File
@@ -0,0 +1,80 @@
package handlers
import (
"context"
"strconv"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/domain/dtos"
"github.com/certimate-go/certimate/internal/rest/resp"
)
type acmeAccountService interface {
List(ctx context.Context, req *dtos.ACMEAccountListReq) (*dtos.ACMEAccountListResp, error)
Import(ctx context.Context, req *dtos.ACMEAccountImportReq) (*dtos.ACMEAccountImportResp, error)
Export(ctx context.Context, accountId string) (*dtos.ACMEAccountExportResp, error)
Rotate(ctx context.Context, accountId string) (*dtos.ACMEAccountRotateResp, error)
}
type ACMEAccountsHandler struct {
service acmeAccountService
}
func NewACMEAccountsHandler(router *router.RouterGroup[*core.RequestEvent], service acmeAccountService) {
handler := &ACMEAccountsHandler{service: service}
group := router.Group("/acme-accounts")
group.GET("", handler.list)
group.POST("/import", handler.importAccount)
group.POST("/{accountId}/export", handler.exportAccount)
group.POST("/{accountId}/rotate", handler.rotateAccount)
}
func (handler *ACMEAccountsHandler) list(e *core.RequestEvent) error {
page, _ := strconv.Atoi(e.Request.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(e.Request.URL.Query().Get("perPage"))
ca := e.Request.URL.Query().Get("ca")
res, err := handler.service.List(e.Request.Context(), &dtos.ACMEAccountListReq{
Page: page,
PerPage: perPage,
CA: ca,
})
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
func (handler *ACMEAccountsHandler) importAccount(e *core.RequestEvent) error {
req := &dtos.ACMEAccountImportReq{}
if err := e.BindBody(req); err != nil {
return resp.Err(e, err)
}
res, err := handler.service.Import(e.Request.Context(), req)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
func (handler *ACMEAccountsHandler) exportAccount(e *core.RequestEvent) error {
accountId := e.Request.PathValue("accountId")
res, err := handler.service.Export(e.Request.Context(), accountId)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
func (handler *ACMEAccountsHandler) rotateAccount(e *core.RequestEvent) error {
accountId := e.Request.PathValue("accountId")
res, err := handler.service.Rotate(e.Request.Context(), accountId)
if err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, res)
}
+3
View File
@@ -5,6 +5,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/certimate-go/certimate/internal/acmeaccount"
"github.com/certimate-go/certimate/internal/certificate"
"github.com/certimate-go/certimate/internal/notify"
"github.com/certimate-go/certimate/internal/pluginhost"
@@ -37,6 +38,7 @@ func BindRouter(router *router.Router[*core.RequestEvent]) {
statisticsSvc = statistics.NewStatisticsService(statisticsRepo)
notifySvc = notify.NewNotifyService(accessRepo)
providerSchemaSvc = providerschema.NewProviderSchemaService(providerSchemaRepo)
acmeAccountSvc := acmeaccount.NewService(acmeAccountRepo)
group := router.Group("/api")
group.Bind(apis.RequireSuperuserAuth())
@@ -48,4 +50,5 @@ func BindRouter(router *router.Router[*core.RequestEvent]) {
handlers.NewPluginCatalogHandler(group, pluginhost.GlobalCatalog())
handlers.NewPluginAdminHandler(group)
handlers.NewPluginMarketHandler(group, pluginhost.GlobalMarketService())
handlers.NewACMEAccountsHandler(group, acmeAccountSvc)
}
+59
View File
@@ -0,0 +1,59 @@
import { type ACMEAccountImportPayload, type ACMEAccountView } from "@/domain/acmeAccount";
import { get as httpGet, post as httpPost } from "./_api";
export type ListACMEAccountsParams = {
page?: number;
perPage?: number;
ca?: string;
};
export const list = (params?: ListACMEAccountsParams) => {
type RespData = {
items: ACMEAccountView[];
totalItems: number;
page: number;
perPage: number;
};
const qs = new URLSearchParams();
if (params?.page != null) qs.set("page", String(params.page));
if (params?.perPage != null) qs.set("perPage", String(params.perPage));
if (params?.ca) qs.set("ca", params.ca);
const query = qs.toString();
return httpGet<RespData>({
url: query ? `/api/acme-accounts?${query}` : "/api/acme-accounts",
});
};
export const importAccount = (payload: ACMEAccountImportPayload) => {
type RespData = {
item: ACMEAccountView;
};
return httpPost<RespData>({
url: "/api/acme-accounts/import",
body: payload,
});
};
export const exportAccount = (accountId: string) => {
type RespData = {
privateKeyPem: string;
};
return httpPost<RespData>({
url: `/api/acme-accounts/${encodeURIComponent(accountId)}/export`,
});
};
export const rotateAccount = (accountId: string) => {
type RespData = {
item: ACMEAccountView;
};
return httpPost<RespData>({
url: `/api/acme-accounts/${encodeURIComponent(accountId)}/rotate`,
});
};
+16
View File
@@ -0,0 +1,16 @@
export interface ACMEAccountView {
id: string;
ca: string;
email: string;
acmeDirUrl: string;
acmeAcctUrl: string;
created: string;
updated: string;
}
export interface ACMEAccountImportPayload {
privateKeyPem: string;
ca: string;
acmeDirUrl?: string;
email?: string;
}
@@ -100,6 +100,62 @@
"tab": "Plugins"
},
"acmeaccounts": {
"tab": "ACME accounts",
"title": "ACME account credentials",
"tips": "Manage ACME account private keys used for certificate issuance. Export enables interoperability with other ACME clients; rotate replaces the key at the CA (old key becomes invalid immediately).",
"empty": "No ACME accounts stored yet. Accounts are created automatically when certificates are issued, or you can import an existing private key.",
"filter": {
"ca_placeholder": "Filter by CA"
},
"table": {
"ca": "Certificate authority",
"email": "Email",
"account_uri": "Account URI",
"directory": "Directory URL",
"created": "Created",
"actions": "Actions"
},
"action": {
"import": "Import",
"export": "Export",
"rotate": "Rotate key"
},
"import": {
"modal": {
"title": "Import ACME account"
},
"form": {
"ca": "Certificate authority",
"ca_placeholder": "Select CA",
"directory": "ACME directory URL",
"directory_placeholder": "https://example.com/acme/directory",
"private_key": "Account private key (PEM)",
"private_key_placeholder": "-----BEGIN ... PRIVATE KEY-----",
"private_key_extra": "The Account URI is resolved automatically via the ACME server (existing account only).",
"email": "Email",
"email_placeholder": "Optional if the CA returns a contact email",
"email_extra": "Required when the CA account has no mailto contact."
}
},
"export": {
"confirm": {
"title": "Export private key?",
"content": "The ACME account private key controls certificate issuance and revocation for this account. Only export it to a trusted environment."
},
"modal": {
"title": "Account private key",
"tips": "Store this key securely. Anyone with this key can act as this ACME account."
}
},
"rotate": {
"confirm": {
"title": "Rotate account key?",
"content": "The CA will invalidate the current private key immediately. Ensure no other ACME clients still use the old key. The Account URI stays the same."
}
}
},
"sslprovider": {
"tab": "Certificate authority",
"ca": {
@@ -100,6 +100,62 @@
"tab": "插件"
},
"acmeaccounts": {
"tab": "ACME 账户",
"title": "ACME 账户凭据",
"tips": "管理用于签发证书的 ACME 账户私钥。导出可与其他 ACME 客户端互通;轮转会在 CA 侧更换密钥(旧密钥立即失效)。",
"empty": "尚无已存储的 ACME 账户。签发证书时会自动创建账户,也可导入已有私钥。",
"filter": {
"ca_placeholder": "按 CA 筛选"
},
"table": {
"ca": "证书颁发机构",
"email": "邮箱",
"account_uri": "Account URI",
"directory": "Directory URL",
"created": "创建时间",
"actions": "操作"
},
"action": {
"import": "导入",
"export": "导出",
"rotate": "轮转密钥"
},
"import": {
"modal": {
"title": "导入 ACME 账户"
},
"form": {
"ca": "证书颁发机构",
"ca_placeholder": "请选择 CA",
"directory": "ACME Directory URL",
"directory_placeholder": "https://example.com/acme/directory",
"private_key": "账户私钥(PEM)",
"private_key_placeholder": "-----BEGIN ... PRIVATE KEY-----",
"private_key_extra": "将通过 ACME 服务器自动探测 Account URI(仅恢复已存在账户,不会新建)。",
"email": "邮箱",
"email_placeholder": "若 CA 返回联系邮箱可留空",
"email_extra": "当 CA 账户没有 mailto 联系方式时必填。"
}
},
"export": {
"confirm": {
"title": "导出私钥?",
"content": "ACME 账户私钥可用于签发与吊销证书。请仅导出到可信环境。"
},
"modal": {
"title": "账户私钥",
"tips": "请妥善保管。持有该私钥即可冒充此 ACME 账户。"
}
},
"rotate": {
"confirm": {
"title": "轮转账户密钥?",
"content": "CA 将立即作废当前私钥。请确认没有其他 ACME 客户端仍在使用旧密钥。Account URI 保持不变。"
}
}
},
"sslprovider": {
"tab": "证书颁发机构",
"ca": {
+2 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconPalette, IconPlugConnected, IconPuzzle, IconUserShield } from "@tabler/icons-react";
import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconKey, IconPalette, IconPlugConnected, IconPuzzle, IconUserShield } from "@tabler/icons-react";
import { Menu } from "antd";
const Settings = () => {
@@ -14,6 +14,7 @@ const Settings = () => {
["account", "settings.account.tab", <IconUserShield size="1em" />],
["appearance", "settings.appearance.tab", <IconPalette size="1em" />],
["ssl-provider", "settings.sslprovider.tab", <IconPlugConnected size="1em" />],
["acme-accounts", "settings.acmeaccounts.tab", <IconKey size="1em" />],
["plugins", "settings.plugins.tab", <IconPuzzle size="1em" />],
["persistence", "settings.persistence.tab", <IconDatabaseCog size="1em" />],
["diagnostics", "settings.diagnostics.tab", <IconHeartRateMonitor size="1em" />],
@@ -0,0 +1,340 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconDownload, IconPlus, IconRefresh } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { App, Button, Empty, Form, Input, Modal, Select, Space, Table, Typography, type TableProps } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import dayjs from "dayjs";
import { saveAs } from "file-saver";
import { z } from "zod";
import { exportAccount, importAccount, list as listAcmeAccounts, rotateAccount } from "@/api/acmeAccounts";
import Show from "@/components/Show";
import { type ACMEAccountView } from "@/domain/acmeAccount";
import { CA_PROVIDERS, caProvidersMap } from "@/domain/provider";
import { useAntdForm, useAppSettings } from "@/hooks";
import { unwrapErrMsg } from "@/utils/error";
const SettingsAcmeAccounts = () => {
const { t } = useTranslation();
const { message, modal, notification } = App.useApp();
const { appSettings: globalAppSettings } = useAppSettings();
const [caFilter, setCaFilter] = useState<string | undefined>();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() => globalAppSettings.defaultPerPage ?? 15);
const {
data: listData,
loading,
refresh,
} = useRequest(
async () => {
const res = await listAcmeAccounts({
page,
perPage: pageSize,
ca: caFilter || undefined,
});
return {
items: res.data?.items ?? [],
totalItems: res.data?.totalItems ?? 0,
};
},
{
refreshDeps: [page, pageSize, caFilter],
onError: (err) => {
notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) });
},
}
);
const tableData = listData?.items ?? [];
const tableTotal = listData?.totalItems ?? 0;
const [importOpen, setImportOpen] = useState(false);
const [exportPem, setExportPem] = useState<{ id: string; pem: string } | null>(null);
const caOptions = useMemo(() => {
return Array.from(caProvidersMap.values()).map((p) => ({
value: p.type,
label: t(p.name),
}));
}, [t]);
const importSchema = z
.object({
privateKeyPem: z.string().min(1, t("common.errmsg.form_invalid")),
ca: z.string().min(1, t("common.errmsg.form_invalid")),
acmeDirUrl: z.string().optional(),
email: z.union([z.string().email(), z.literal("")]).optional(),
})
.superRefine((val, ctx) => {
if (val.ca === CA_PROVIDERS.ACMECA && !val.acmeDirUrl?.trim()) {
ctx.addIssue({ code: "custom", message: t("common.errmsg.form_invalid"), path: ["acmeDirUrl"] });
}
});
const importRule = createSchemaFieldRule(importSchema);
const {
form: importForm,
formPending: importPending,
formProps: importFormProps,
} = useAntdForm<z.infer<typeof importSchema>>({
onSubmit: async (values) => {
try {
await importAccount({
privateKeyPem: values.privateKeyPem,
ca: values.ca,
acmeDirUrl: values.acmeDirUrl || undefined,
email: values.email || undefined,
});
message.success(t("common.text.operation_succeeded"));
setImportOpen(false);
importForm.resetFields();
setPage(1);
refresh();
} catch (err) {
notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) });
}
},
});
const caWatch = Form.useWatch("ca", importForm);
const handleExport = (record: ACMEAccountView) => {
modal.confirm({
title: t("settings.acmeaccounts.export.confirm.title"),
content: t("settings.acmeaccounts.export.confirm.content"),
okText: t("common.button.confirm"),
cancelText: t("common.button.cancel"),
okButtonProps: { danger: true },
onOk: async () => {
try {
const res = await exportAccount(record.id);
setExportPem({ id: record.id, pem: res.data.privateKeyPem });
} catch (err) {
notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) });
}
},
});
};
const handleRotate = (record: ACMEAccountView) => {
modal.confirm({
title: t("settings.acmeaccounts.rotate.confirm.title"),
content: t("settings.acmeaccounts.rotate.confirm.content"),
okText: t("common.button.confirm"),
cancelText: t("common.button.cancel"),
okButtonProps: { danger: true },
onOk: async () => {
try {
await rotateAccount(record.id);
message.success(t("common.text.operation_succeeded"));
refresh();
} catch (err) {
notification.error({ title: t("common.text.request_error"), description: unwrapErrMsg(err) });
}
},
});
};
const handlePaginationChange = (nextPage: number, nextPageSize: number) => {
setPage(nextPage);
setPageSize(nextPageSize);
};
const handleCaFilterChange = (value: string | undefined) => {
setCaFilter(value);
setPage(1);
};
const providerLabel = (ca: string) => {
const p = caProvidersMap.get(ca);
return p ? t(p.name) : ca;
};
const columns: TableProps<ACMEAccountView>["columns"] = [
{
key: "ca",
title: t("settings.acmeaccounts.table.ca"),
dataIndex: "ca",
width: 180,
ellipsis: true,
render: (v: string) => providerLabel(v),
},
{
key: "email",
title: t("settings.acmeaccounts.table.email"),
dataIndex: "email",
ellipsis: true,
},
{
key: "acmeAcctUrl",
title: t("settings.acmeaccounts.table.account_uri"),
dataIndex: "acmeAcctUrl",
ellipsis: true,
render: (v: string) => (
<Typography.Text copyable={{ text: v }} ellipsis className="max-w-full">
{v}
</Typography.Text>
),
},
{
key: "acmeDirUrl",
title: t("settings.acmeaccounts.table.directory"),
dataIndex: "acmeDirUrl",
ellipsis: true,
responsive: ["lg"],
},
{
key: "created",
title: t("settings.acmeaccounts.table.created"),
dataIndex: "created",
width: 160,
responsive: ["xl"],
render: (v: string) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm") : "-"),
},
{
key: "$action",
title: t("settings.acmeaccounts.table.actions"),
width: 200,
align: "end",
fixed: "right",
render: (_, record) => (
<Space size="small">
<Button type="link" size="small" icon={<IconDownload size="1em" />} onClick={() => handleExport(record)}>
{t("settings.acmeaccounts.action.export")}
</Button>
<Button type="link" size="small" danger icon={<IconRefresh size="1em" />} onClick={() => handleRotate(record)}>
{t("settings.acmeaccounts.action.rotate")}
</Button>
</Space>
),
},
];
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="!mb-1">{t("settings.acmeaccounts.title")}</h2>
<Typography.Paragraph type="secondary" className="!mb-0">
{t("settings.acmeaccounts.tips")}
</Typography.Paragraph>
</div>
<Space wrap>
<Select
allowClear
className="min-w-48"
options={caOptions}
placeholder={t("settings.acmeaccounts.filter.ca_placeholder")}
showSearch
optionFilterProp="label"
value={caFilter}
onChange={handleCaFilterChange}
/>
<Button icon={<IconRefresh size="1.1em" />} onClick={() => refresh()} loading={loading}>
{t("common.button.reload")}
</Button>
<Button type="primary" icon={<IconPlus size="1.1em" />} onClick={() => setImportOpen(true)}>
{t("settings.acmeaccounts.action.import")}
</Button>
</Space>
</div>
<Table
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={tableData}
scroll={{ x: "max(100%, 960px)" }}
locale={{
emptyText: (
<Empty
description={!loading ? t("settings.acmeaccounts.empty") : " "}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
),
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
/>
<Modal
title={t("settings.acmeaccounts.import.modal.title")}
open={importOpen}
onCancel={() => {
setImportOpen(false);
importForm.resetFields();
}}
onOk={() => importForm.submit()}
confirmLoading={importPending}
destroyOnHidden
width={640}
>
<Form {...importFormProps} form={importForm} layout="vertical" className="mt-4">
<Form.Item name="ca" label={t("settings.acmeaccounts.import.form.ca")} rules={[importRule]}>
<Select options={caOptions} showSearch optionFilterProp="label" placeholder={t("settings.acmeaccounts.import.form.ca_placeholder")} />
</Form.Item>
<Show when={caWatch === CA_PROVIDERS.ACMECA}>
<Form.Item name="acmeDirUrl" label={t("settings.acmeaccounts.import.form.directory")} rules={[importRule]}>
<Input placeholder={t("settings.acmeaccounts.import.form.directory_placeholder")} />
</Form.Item>
</Show>
<Form.Item
name="privateKeyPem"
label={t("settings.acmeaccounts.import.form.private_key")}
rules={[importRule]}
extra={t("settings.acmeaccounts.import.form.private_key_extra")}
>
<Input.TextArea autoSize={{ minRows: 6, maxRows: 12 }} placeholder={t("settings.acmeaccounts.import.form.private_key_placeholder")} />
</Form.Item>
<Form.Item name="email" label={t("settings.acmeaccounts.import.form.email")} rules={[importRule]} extra={t("settings.acmeaccounts.import.form.email_extra")}>
<Input placeholder={t("settings.acmeaccounts.import.form.email_placeholder")} />
</Form.Item>
</Form>
</Modal>
<Modal
title={t("settings.acmeaccounts.export.modal.title")}
open={!!exportPem}
onCancel={() => setExportPem(null)}
footer={[
<Button
key="copy"
onClick={async () => {
if (!exportPem) return;
await navigator.clipboard.writeText(exportPem.pem);
message.success(t("common.text.copied"));
}}
>
{t("common.button.copy")}
</Button>,
<Button
key="download"
type="primary"
onClick={() => {
if (!exportPem) return;
const blob = new Blob([exportPem.pem], { type: "application/x-pem-file" });
saveAs(blob, `acme-account-${exportPem.id}.pem`);
}}
>
{t("common.button.download")}
</Button>,
]}
width={640}
>
<Typography.Paragraph type="secondary">{t("settings.acmeaccounts.export.modal.tips")}</Typography.Paragraph>
<Input.TextArea value={exportPem?.pem ?? ""} readOnly autoSize={{ minRows: 8, maxRows: 16 }} />
</Modal>
</div>
);
};
export default SettingsAcmeAccounts;
+5
View File
@@ -12,6 +12,7 @@ import PresetList from "@/pages/presets/PresetList";
import Settings from "@/pages/settings/Settings";
import SettingsAbout from "@/pages/settings/SettingsAbout";
import SettingsAccount from "@/pages/settings/SettingsAccount";
import SettingsAcmeAccounts from "@/pages/settings/SettingsAcmeAccounts";
import SettingsAppearance from "@/pages/settings/SettingsAppearance";
import SettingsDiagnostics from "@/pages/settings/SettingsDiagnostics";
import SettingsPlugins from "@/pages/settings/SettingsPlugins";
@@ -86,6 +87,10 @@ export const router = createHashRouter([
path: "/settings/ssl-provider",
element: <SettingsSSLProvider />,
},
{
path: "/settings/acme-accounts",
element: <SettingsAcmeAccounts />,
},
{
path: "/settings/persistence",
element: <SettingsPersistence />,