mirror of
https://github.com/certimate-go/certimate.git
synced 2026-10-16 15:16:05 +08:00
feat: acme accounts
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user