diff --git a/backend/internal/cert/apply/apply.go b/backend/internal/cert/apply/apply.go index 3bfd179..f340141 100644 --- a/backend/internal/cert/apply/apply.go +++ b/backend/internal/cert/apply/apply.go @@ -3,6 +3,7 @@ package apply import ( "ALLinSSL/backend/internal/access" "ALLinSSL/backend/internal/cert" + "ALLinSSL/backend/internal/cert/apply/lego/acmedns" "ALLinSSL/backend/internal/cert/apply/lego/bt" "ALLinSSL/backend/internal/cert/apply/lego/jdcloud" "ALLinSSL/backend/internal/cert/apply/lego/webhook" @@ -242,6 +243,12 @@ func GetDNSProvider(providerName string, creds map[string]string, httpClient *ht config.SecretKey = creds["secret_key"] config.PropagationTimeout = maxWait return edgeone.NewDNSProviderConfig(config) + case "acmedns": + config := &acmedns.Config{ + ServerURL: creds["server_url"], + Credentials: creds["credentials"], + } + return acmedns.NewDNSProviderConfig(config) default: return nil, fmt.Errorf("不支持的 DNS Provider: %s", providerName) diff --git a/backend/internal/cert/apply/lego/acmedns/lego.go b/backend/internal/cert/apply/lego/acmedns/lego.go new file mode 100644 index 0000000..de359a8 --- /dev/null +++ b/backend/internal/cert/apply/lego/acmedns/lego.go @@ -0,0 +1,125 @@ +package acmedns + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + legoacmedns "github.com/go-acme/lego/v4/providers/dns/acmedns" + "github.com/nrdcg/goacmedns" +) + +type Config struct { + ServerURL string `json:"server_url"` + Credentials string `json:"credentials"` +} + +type acmeDNSUpdater interface { + UpdateTXTRecord(ctx context.Context, account goacmedns.Account, value string) error +} + +type singleAccountProvider struct { + account goacmedns.Account + client acmeDNSUpdater +} + +var _ challenge.Provider = (*singleAccountProvider)(nil) + +func NewDNSProviderConfig(config *Config) (challenge.Provider, error) { + if config == nil { + return nil, errors.New("acme-dns: the configuration of the DNS provider is nil") + } + + serverURL := strings.TrimSpace(config.ServerURL) + if serverURL == "" { + return nil, errors.New("acme-dns: server_url is required") + } + + credentials := strings.TrimSpace(config.Credentials) + if credentials == "" { + return nil, errors.New("acme-dns: credentials is required") + } + + if isAccountMappingCredentials(credentials) { + return newStorageBackedProvider(serverURL, credentials) + } + + account, err := parseSingleAccountCredentials(credentials) + if err != nil { + return nil, err + } + + client, err := goacmedns.NewClient(serverURL) + if err != nil { + return nil, err + } + + account.ServerURL = serverURL + + return &singleAccountProvider{ + account: account, + client: client, + }, nil +} + +func createTempCredentialsFile(credentials string) (string, error) { + tempFile, err := os.CreateTemp("", "allinssl-acmedns-*.json") + if err != nil { + return "", fmt.Errorf("acme-dns: failed to create temp credentials file: %w", err) + } + defer tempFile.Close() + + if _, err := tempFile.WriteString(credentials); err != nil { + return "", fmt.Errorf("acme-dns: failed to write temp credentials file: %w", err) + } + + return tempFile.Name(), nil +} + +func (p *singleAccountProvider) Present(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + return p.client.UpdateTXTRecord(context.Background(), p.account, info.Value) +} + +func (p *singleAccountProvider) CleanUp(_, _, _ string) error { + return nil +} + +func isAccountMappingCredentials(credentials string) bool { + var accounts map[string]goacmedns.Account + + return json.Unmarshal([]byte(credentials), &accounts) == nil +} + +func parseSingleAccountCredentials(credentials string) (goacmedns.Account, error) { + var account goacmedns.Account + + if err := json.Unmarshal([]byte(credentials), &account); err != nil { + return goacmedns.Account{}, fmt.Errorf("acme-dns: credentials must be either a /register account JSON or a map[domain]account JSON: %w", err) + } + + if strings.TrimSpace(account.Username) == "" || strings.TrimSpace(account.Password) == "" || strings.TrimSpace(account.SubDomain) == "" { + return goacmedns.Account{}, errors.New("acme-dns: single-account credentials require username, password, and subdomain") + } + + return account, nil +} + +func newStorageBackedProvider(serverURL, credentials string) (challenge.Provider, error) { + tempFilePath, err := createTempCredentialsFile(credentials) + if err != nil { + return nil, err + } + + providerConfig := legoacmedns.NewDefaultConfig() + providerConfig.APIBase = serverURL + providerConfig.StoragePath = tempFilePath + + return legoacmedns.NewDNSProviderConfig(providerConfig) +} diff --git a/backend/migrations/init.go b/backend/migrations/init.go index 27f0ee4..7a83b78 100644 --- a/backend/migrations/init.go +++ b/backend/migrations/init.go @@ -201,6 +201,7 @@ func init() { InsertIfNotExists(db, "access_type", map[string]any{"name": "btdomain", "type": "dns"}, []string{"name", "type"}, []any{"btdomain", "dns"}) InsertIfNotExists(db, "access_type", map[string]any{"name": "edgeone", "type": "dns"}, []string{"name", "type"}, []any{"edgeone", "dns"}) + InsertIfNotExists(db, "access_type", map[string]any{"name": "acmedns", "type": "dns"}, []string{"name", "type"}, []any{"acmedns", "dns"}) err = sqlite_migrate.EnsureDatabaseWithTables( "data/site_monitor.db", diff --git a/frontend/apps/allin-ssl/src/assets/icons/svg/resources/acmedns.svg b/frontend/apps/allin-ssl/src/assets/icons/svg/resources/acmedns.svg new file mode 100644 index 0000000..6c5eaee --- /dev/null +++ b/frontend/apps/allin-ssl/src/assets/icons/svg/resources/acmedns.svg @@ -0,0 +1 @@ + diff --git a/frontend/apps/allin-ssl/src/config/data.tsx b/frontend/apps/allin-ssl/src/config/data.tsx index e6a3048..be18e6e 100644 --- a/frontend/apps/allin-ssl/src/config/data.tsx +++ b/frontend/apps/allin-ssl/src/config/data.tsx @@ -297,6 +297,12 @@ export const ApiProjectConfig: Record = { }, sort: 38, }, + acmedns: { + name: "ACME DNS", + icon: "acmedns", + type: ["dns"], + sort: 39, + }, plugin: { name: "插件", icon: "plugin", diff --git a/frontend/apps/allin-ssl/src/types/access.d.ts b/frontend/apps/allin-ssl/src/types/access.d.ts index 4676a26..a86edd9 100644 --- a/frontend/apps/allin-ssl/src/types/access.d.ts +++ b/frontend/apps/allin-ssl/src/types/access.d.ts @@ -64,6 +64,7 @@ export interface AddAccessParams< | LecdnAccessConfig | ConstellixAccessConfig | WebhookAccessConfig + | AcmeDnsAccessConfig | SpaceshipAccessConfig | BTDomainAccessConfig > { @@ -102,6 +103,7 @@ export interface UpdateAccessParams< | LecdnAccessConfig | ConstellixAccessConfig | WebhookAccessConfig + | AcmeDnsAccessConfig | SpaceshipAccessConfig | BTDomainAccessConfig > extends AddAccessParams { @@ -320,6 +322,11 @@ export interface WebhookAccessConfig { ignore_ssl: "0" | "1"; } +export interface AcmeDnsAccessConfig { + server_url: string; + credentials: string; +} + export interface SpaceshipAccessConfig { api_key: string; api_secret: string; diff --git a/frontend/apps/allin-ssl/src/views/authApiManage/useController.tsx b/frontend/apps/allin-ssl/src/views/authApiManage/useController.tsx index 7a9253f..d666ebb 100644 --- a/frontend/apps/allin-ssl/src/views/authApiManage/useController.tsx +++ b/frontend/apps/allin-ssl/src/views/authApiManage/useController.tsx @@ -55,6 +55,7 @@ import type { LecdnAccessConfig, ConstellixAccessConfig, WebhookAccessConfig, + AcmeDnsAccessConfig, SpaceshipAccessConfig, BTDomainAccessConfig, } from "@/types/access"; @@ -554,6 +555,39 @@ export const useApiFormController = ( callback(); }, }, + server_url: { + required: true, + trigger: "input", + validator: ( + rule: FormItemRule, + value: string, + callback: (error?: Error) => void + ) => { + if (!isUrl(value)) { + return callback(new Error("请输入正确的 Server URL")); + } + callback(); + }, + }, + credentials: { + required: true, + trigger: "input", + validator: ( + rule: FormItemRule, + value: string, + callback: (error?: Error) => void + ) => { + if (!value || value.trim() === "") { + return callback(new Error("请输入 Credentials JSON")); + } + try { + JSON.parse(value); + } catch (error) { + return callback(new Error("Credentials 必须是合法 JSON")); + } + callback(); + }, + }, api_key: { trigger: "input", validator: ( @@ -1320,6 +1354,45 @@ export const useApiFormController = ( }) ); break; + case "acmedns": + items.push( + useFormInput("Server URL", "config.server_url", { + allowInput: noSideSpace, + placeholder: "https://auth.acme-dns.io", + }), + useFormTextarea( + "Credentials(JSON)", + "config.credentials", + { + rows: 8, + placeholder: + '{\n "username": "xxx",\n "password": "xxx",\n "subdomain": "xxx",\n "fulldomain": "xxx.auth.acme-dns.io"\n}', + } + ), + useFormCustom(() => { + return ( + + + Credentials 需要填写 acme-dns /register{" "} + 接口返回的单条 JSON。 + + 官方项目地址 + + + + ); + }) + ); + break; case "spaceship": items.push( useFormInput("API Key", "config.api_key", { @@ -1728,6 +1801,12 @@ export const useApiFormController = ( ignore_ssl: "0", } as WebhookAccessConfig; break; + case "acmedns": + param.value.config = { + server_url: "https://auth.acme-dns.io", + credentials: "", + } as AcmeDnsAccessConfig; + break; case "spaceship": param.value.config = { api_key: "", diff --git a/go.mod b/go.mod index 76427cd..43db1f6 100644 --- a/go.mod +++ b/go.mod @@ -119,6 +119,7 @@ require ( github.com/namedotcom/go/v4 v4.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nrdcg/bunny-go v0.1.0 // indirect + github.com/nrdcg/goacmedns v0.2.0 // indirect github.com/nrdcg/namesilo v0.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect diff --git a/go.sum b/go.sum index 486b0de..f9353c1 100644 --- a/go.sum +++ b/go.sum @@ -598,6 +598,8 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nrdcg/bunny-go v0.1.0 h1:GAHTRpHaG/TxfLZlqoJ8OJFzw8rI74+jOTkzxWh0uHA= github.com/nrdcg/bunny-go v0.1.0/go.mod h1:u+C9dgsspgtWVaAz6QkyV17s9fxD8viwwKoxb9XMz1A= +github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0= +github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= github.com/nrdcg/namesilo v0.5.0 h1:6QNxT/XxE+f5B+7QlfWorthNzOzcGlBLRQxqi6YeBrE= github.com/nrdcg/namesilo v0.5.0/go.mod h1:4UkwlwQfDt74kSGmhLaDylnBrD94IfflnpoEaj6T2qw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=