feat: add DNS credential management with Web UI

- New config/credential_store.go: persistent credential storage (credentials.json)
- New API: CRUD for DNS credentials + credential-types endpoint
- Certificate model now supports credential_id reference
- Create certificate auto-resolves credential_id to DNS config
- New Vue page: Credentials.vue for managing saved DNS keys
- CertCreate.vue: select existing credential or manual input + save as new
- Secrets masked in API responses, never exposed in list
This commit is contained in:
2026-07-24 18:56:03 +08:00
parent adebf82aaa
commit bdb3ca856f
16 changed files with 1032 additions and 139 deletions
-2
View File
@@ -6,8 +6,6 @@ backend/data/
# 编译产物 # 编译产物
backend/autossl backend/autossl
# 预编译二进制(手动管理)
backend/bin/
node_modules/ node_modules/
# IDE # IDE
+1
View File
@@ -13,6 +13,7 @@ type Certificate struct {
ChallengeType string `json:"challenge_type"` // http, dns ChallengeType string `json:"challenge_type"` // http, dns
DNSProvider string `json:"dns_provider,omitempty"` // alidns, cloudflare, etc. DNSProvider string `json:"dns_provider,omitempty"` // alidns, cloudflare, etc.
DNSConfig string `json:"dns_config,omitempty"` // JSON config for DNS provider DNSConfig string `json:"dns_config,omitempty"` // JSON config for DNS provider
CredentialID *uint `json:"credential_id,omitempty"` // references saved credential
Status string `json:"status"` // pending, active, expired, error Status string `json:"status"` // pending, active, expired, error
CertURL string `json:"cert_url,omitempty"` CertURL string `json:"cert_url,omitempty"`
+1
View File
@@ -76,6 +76,7 @@ func InitStore(cfg *Config) {
func InitAll(cfg *Config) { func InitAll(cfg *Config) {
InitStore(cfg) InitStore(cfg)
InitNotifyStore(cfg) InitNotifyStore(cfg)
InitCredentialStore(cfg)
} }
// Load reads certificates from JSON file into memory // Load reads certificates from JSON file into memory
+226
View File
@@ -0,0 +1,226 @@
package config
import (
"encoding/json"
"log"
"os"
"sync"
"time"
)
// CredentialType represents supported DNS provider types
type CredentialType string
const (
CredentialAliDNS CredentialType = "alidns"
CredentialCloudflare CredentialType = "cloudflare"
CredentialDNSPod CredentialType = "dnspod"
)
// CredentialField describes a single field of a credential type
type CredentialField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text, password
Required bool `json:"required"`
}
// CredentialTypeMeta returns field definitions for each provider type
func CredentialTypeMeta(ctype CredentialType) []CredentialField {
switch ctype {
case CredentialAliDNS:
return []CredentialField{
{Key: "ali_key", Label: "AccessKey ID", Type: "text", Required: true},
{Key: "ali_secret", Label: "AccessKey Secret", Type: "password", Required: true},
}
case CredentialCloudflare:
return []CredentialField{
{Key: "cf_api_token", Label: "API Token", Type: "password", Required: true},
}
case CredentialDNSPod:
return []CredentialField{
{Key: "dnspod_id", Label: "ID", Type: "text", Required: true},
{Key: "dnspod_key", Label: "Key", Type: "password", Required: true},
}
default:
return nil
}
}
// Credential holds saved DNS provider credentials
type Credential struct {
ID uint `json:"id"`
Name string `json:"name"` // user-friendly name
Type CredentialType `json:"type"` // alidns, cloudflare, dnspod
Data string `json:"data"` // JSON string of key-value pairs
Masked string `json:"masked"` // masked display version of data
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CredentialStore manages credential persistence
type CredentialStore struct {
mu sync.RWMutex
data map[uint]*Credential
path string
nextID uint
}
var CredStore *CredentialStore
// InitCredentialStore initializes the credential store
func InitCredentialStore(cfg *Config) {
CredStore = &CredentialStore{
data: make(map[uint]*Credential),
path: cfg.DataDir + "/credentials.json",
}
if err := CredStore.Load(); err != nil {
log.Printf("No existing credential store: %v, starting fresh", err)
}
log.Println("Credential store initialized successfully")
}
// Load reads credentials from JSON file
func (s *CredentialStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
var creds []*Credential
if err := json.Unmarshal(data, &creds); err != nil {
return err
}
s.data = make(map[uint]*Credential)
s.nextID = 0
for _, c := range creds {
s.data[c.ID] = c
if c.ID >= s.nextID {
s.nextID = c.ID + 1
}
}
return nil
}
func (s *CredentialStore) save() error {
creds := make([]*Credential, 0, len(s.data))
for _, c := range s.data {
creds = append(creds, c)
}
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0600)
}
// GetAll returns all credentials
func (s *CredentialStore) GetAll() []*Credential {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Credential, 0, len(s.data))
for _, c := range s.data {
clone := *c
clone.Data = "" // don't expose secrets in list
result = append(result, &clone)
}
// Sort by ID descending (newest first)
for i := 0; i < len(result)-1; i++ {
for j := i + 1; j < len(result); j++ {
if result[i].ID < result[j].ID {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
// GetByID returns a credential with secrets (for internal use)
func (s *CredentialStore) GetByID(id uint) *Credential {
s.mu.RLock()
defer s.mu.RUnlock()
c := s.data[id]
if c == nil {
return nil
}
clone := *c
return &clone
}
// Create adds a new credential
func (s *CredentialStore) Create(cred *Credential) error {
s.mu.Lock()
defer s.mu.Unlock()
cred.ID = s.nextID
s.nextID++
now := time.Now()
cred.CreatedAt = now
cred.UpdatedAt = now
cred.Masked = maskCredentialData(cred.Type, cred.Data)
s.data[cred.ID] = cred
return s.save()
}
// Update modifies an existing credential, keeping data if masked
func (s *CredentialStore) Update(cred *Credential) error {
s.mu.Lock()
defer s.mu.Unlock()
existing, ok := s.data[cred.ID]
if !ok {
return nil
}
// If data is masked, keep the existing one
if cred.Data == "********" {
cred.Data = existing.Data
}
cred.CreatedAt = existing.CreatedAt
cred.UpdatedAt = time.Now()
cred.Masked = maskCredentialData(cred.Type, cred.Data)
s.data[cred.ID] = cred
return s.save()
}
// Delete removes a credential
func (s *CredentialStore) Delete(id uint) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, id)
return s.save()
}
// maskCredentialData creates a masked version for API display
func maskCredentialData(ctype CredentialType, dataJSON string) string {
var raw map[string]string
if err := json.Unmarshal([]byte(dataJSON), &raw); err != nil {
return "{}"
}
masked := make(map[string]string)
for k, v := range raw {
if len(v) > 4 {
masked[k] = v[:4] + "****"
} else {
masked[k] = "****"
}
}
b, _ := json.Marshal(masked)
return string(b)
}
// GetDataMap parses the credential data JSON into a map
func (c *Credential) GetDataMap() map[string]string {
result := make(map[string]string)
json.Unmarshal([]byte(c.Data), &result)
return result
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoSSL 证书管理</title> <title>AutoSSL 证书管理</title>
<script type="module" crossorigin src="/assets/index-D6V1aiIx.js"></script> <script type="module" crossorigin src="/assets/index-e3s7Ic6B.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Kwmxr6vY.css"> <link rel="stylesheet" crossorigin href="/assets/index-BDJKlQw8.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+21
View File
@@ -26,6 +26,7 @@ type CreateCertRequest struct {
ChallengeType string `json:"challenge_type"` ChallengeType string `json:"challenge_type"`
DNSProvider string `json:"dns_provider"` DNSProvider string `json:"dns_provider"`
DNSConfig string `json:"dns_config"` DNSConfig string `json:"dns_config"`
CredentialID *uint `json:"credential_id"`
AutoRenew *bool `json:"auto_renew"` AutoRenew *bool `json:"auto_renew"`
RenewDays *int `json:"renew_days"` RenewDays *int `json:"renew_days"`
} }
@@ -51,6 +52,19 @@ func (h *CertHandler) GetCertificate(c *gin.Context) {
c.JSON(http.StatusOK, cert) c.JSON(http.StatusOK, cert)
} }
// resolveCredential fills DNSConfig from credential reference if credential_id is set
func resolveCredential(req *CreateCertRequest) error {
if req.CredentialID != nil && *req.CredentialID > 0 {
cred := config.CredStore.GetByID(*req.CredentialID)
if cred == nil {
return fmt.Errorf("credential not found")
}
req.DNSProvider = string(cred.Type)
req.DNSConfig = cred.Data
}
return nil
}
// CreateCertificate creates a new certificate entry and starts issuance // CreateCertificate creates a new certificate entry and starts issuance
func (h *CertHandler) CreateCertificate(c *gin.Context) { func (h *CertHandler) CreateCertificate(c *gin.Context) {
var req CreateCertRequest var req CreateCertRequest
@@ -68,6 +82,12 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
req.Domain = strings.TrimSpace(req.Domain) req.Domain = strings.TrimSpace(req.Domain)
// Resolve credential reference
if err := resolveCredential(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
autoRenew := true autoRenew := true
renewDays := 30 renewDays := 30
if req.AutoRenew != nil { if req.AutoRenew != nil {
@@ -120,6 +140,7 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
ChallengeType: req.ChallengeType, ChallengeType: req.ChallengeType,
DNSProvider: req.DNSProvider, DNSProvider: req.DNSProvider,
DNSConfig: req.DNSConfig, DNSConfig: req.DNSConfig,
CredentialID: req.CredentialID,
Status: "pending", Status: "pending",
AutoRenew: autoRenew, AutoRenew: autoRenew,
RenewDays: renewDays, RenewDays: renewDays,
+182
View File
@@ -0,0 +1,182 @@
package handlers
import (
"auto-ssl/config"
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type CredentialHandler struct{}
func NewCredentialHandler() *CredentialHandler {
return &CredentialHandler{}
}
type CreateCredentialRequest struct {
Name string `json:"name" binding:"required"`
Type config.CredentialType `json:"type" binding:"required"`
Data string `json:"data" binding:"required"` // JSON key-value pairs
}
type UpdateCredentialRequest struct {
Name string `json:"name"`
Type config.CredentialType `json:"type"`
Data string `json:"data"` // JSON key-value pairs (or "********" to keep)
}
// ListCredentials returns all credentials (without secrets)
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
creds := config.CredStore.GetAll()
c.JSON(http.StatusOK, creds)
}
// GetCredential returns a single credential
func (h *CredentialHandler) GetCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
cred := config.CredStore.GetByID(uint(id))
if cred == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
// Never expose raw secrets in API
cred.Data = cred.Masked
c.JSON(http.StatusOK, cred)
}
// CreateCredential creates a new credential
func (h *CredentialHandler) CreateCredential(c *gin.Context) {
var req CreateCredentialRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate JSON data
var testData map[string]string
if err := json.Unmarshal([]byte(req.Data), &testData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "data must be valid JSON key-value pairs"})
return
}
// Validate fields for the provider type
fields := config.CredentialTypeMeta(req.Type)
for _, f := range fields {
if f.Required {
if _, ok := testData[f.Key]; !ok || testData[f.Key] == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": f.Label + " 是必填项"})
return
}
}
}
cred := &config.Credential{
Name: req.Name,
Type: req.Type,
Data: req.Data,
}
if err := config.CredStore.Create(cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return without secrets
cred.Data = cred.Masked
c.JSON(http.StatusCreated, cred)
}
// UpdateCredential updates a credential
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
existing := config.CredStore.GetByID(uint(id))
if existing == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
var req UpdateCredentialRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
existing.Name = req.Name
}
if req.Type != "" {
existing.Type = req.Type
}
if req.Data != "" {
// If it's the masked placeholder, keep existing data
if req.Data == "********" {
existing.Data = existing.Data
} else {
var testData map[string]string
if err := json.Unmarshal([]byte(req.Data), &testData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "data must be valid JSON"})
return
}
existing.Data = req.Data
}
}
if err := config.CredStore.Update(existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
existing.Data = existing.Masked
c.JSON(http.StatusOK, existing)
}
// DeleteCredential deletes a credential
func (h *CredentialHandler) DeleteCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := config.CredStore.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "credential deleted"})
}
// GetCredentialTypes returns available credential types and their fields
func (h *CredentialHandler) GetCredentialTypes(c *gin.Context) {
types := []gin.H{
{
"type": config.CredentialAliDNS,
"label": "阿里云 DNS (Aliyun)",
"fields": config.CredentialTypeMeta(config.CredentialAliDNS),
},
{
"type": config.CredentialCloudflare,
"label": "Cloudflare",
"fields": config.CredentialTypeMeta(config.CredentialCloudflare),
},
{
"type": config.CredentialDNSPod,
"label": "DNSPod",
"fields": config.CredentialTypeMeta(config.CredentialDNSPod),
},
}
c.JSON(http.StatusOK, types)
}
+9
View File
@@ -53,6 +53,7 @@ func main() {
api.Use(authMiddleware()) api.Use(authMiddleware())
{ {
certHandler := handlers.NewCertHandler(cfg) certHandler := handlers.NewCertHandler(cfg)
credHandler := handlers.NewCredentialHandler()
// Certificate management // Certificate management
api.GET("/certificates", certHandler.ListCertificates) api.GET("/certificates", certHandler.ListCertificates)
@@ -69,6 +70,14 @@ func main() {
api.POST("/notify/test-feishu", handleTestFeishu) api.POST("/notify/test-feishu", handleTestFeishu)
api.POST("/notify/test-email", handleTestEmail) api.POST("/notify/test-email", handleTestEmail)
// Credential management
api.GET("/credentials", credHandler.ListCredentials)
api.GET("/credentials/:id", credHandler.GetCredential)
api.POST("/credentials", credHandler.CreateCredential)
api.PUT("/credentials/:id", credHandler.UpdateCredential)
api.DELETE("/credentials/:id", credHandler.DeleteCredential)
api.GET("/credential-types", credHandler.GetCredentialTypes)
// Utility // Utility
api.GET("/renewals/check", certHandler.CheckRenewals) api.GET("/renewals/check", certHandler.CheckRenewals)
api.GET("/stats", certHandler.Stats) api.GET("/stats", certHandler.Stats)
+4
View File
@@ -53,6 +53,10 @@
<el-icon><Bell /></el-icon> <el-icon><Bell /></el-icon>
<span>通知配置</span> <span>通知配置</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/credentials">
<el-icon><Key /></el-icon>
<span>DNS 凭证</span>
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-main> <el-main>
+33
View File
@@ -91,6 +91,29 @@ export interface NotifyConfig {
updated_at: string updated_at: string
} }
export interface Credential {
id: number
name: string
type: string
data: string
masked: string
created_at: string
updated_at: string
}
export interface CredentialField {
key: string
label: string
type: string
required: boolean
}
export interface CredentialType {
type: string
label: string
fields: CredentialField[]
}
// Auth API // Auth API
export const authApi = { export const authApi = {
login: (username: string, password: string) => login: (username: string, password: string) =>
@@ -136,3 +159,13 @@ export const notifyApi = {
testFeishu: () => api.post('/notify/test-feishu'), testFeishu: () => api.post('/notify/test-feishu'),
testEmail: () => api.post('/notify/test-email'), testEmail: () => api.post('/notify/test-email'),
} }
// Credential API
export const credApi = {
list: () => api.get<Credential[]>('/credentials'),
get: (id: number) => api.get<Credential>(`/credentials/${id}`),
create: (data: { name: string; type: string; data: string }) => api.post<Credential>('/credentials', data),
update: (id: number, data: { name?: string; type?: string; data?: string }) => api.put<Credential>(`/credentials/${id}`, data),
delete: (id: number) => api.delete(`/credentials/${id}`),
types: () => api.get<CredentialType[]>('/credential-types'),
}
+7
View File
@@ -3,6 +3,7 @@ import Dashboard from '../views/Dashboard.vue'
import CertList from '../views/CertList.vue' import CertList from '../views/CertList.vue'
import CertCreate from '../views/CertCreate.vue' import CertCreate from '../views/CertCreate.vue'
import NotifyConfig from '../views/NotifyConfig.vue' import NotifyConfig from '../views/NotifyConfig.vue'
import Credentials from '../views/Credentials.vue'
import Login from '../views/Login.vue' import Login from '../views/Login.vue'
import { isLoggedIn } from '../api' import { isLoggedIn } from '../api'
@@ -39,6 +40,12 @@ const router = createRouter({
component: NotifyConfig, component: NotifyConfig,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/credentials',
name: 'Credentials',
component: Credentials,
meta: { requiresAuth: true },
},
], ],
}) })
+185 -20
View File
@@ -8,7 +8,7 @@
:model="form" :model="form"
:rules="rules" :rules="rules"
label-width="140px" label-width="140px"
style="max-width: 600px" style="max-width: 620px"
> >
<el-form-item label="域名" prop="domain"> <el-form-item label="域名" prop="domain">
<el-input v-model="form.domain" placeholder="example.com" /> <el-input v-model="form.domain" placeholder="example.com" />
@@ -32,8 +32,36 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="DNS 提供商" v-if="form.challenge_type === 'dns'" prop="dns_provider"> <!-- DNS mode: credential or manual -->
<el-select v-model="form.dns_provider" placeholder="选择 DNS 提供商" style="width: 100%"> <template v-if="form.challenge_type === 'dns'">
<el-form-item label="使用方式">
<el-radio-group v-model="credMode">
<el-radio value="credential">选择已有凭证</el-radio>
<el-radio value="manual">手动输入</el-radio>
</el-radio-group>
</el-form-item>
<!-- Select existing credential -->
<template v-if="credMode === 'credential'">
<el-form-item label="选择凭证">
<el-select v-model="selectedCredId" placeholder="选择已保存的 DNS 凭证" style="width: 100%" @change="onCredSelected">
<el-option
v-for="cred in credentials"
:key="cred.id"
:label="cred.name + ' (' + typeLabel(cred.type) + ')'"
:value="cred.id"
/>
</el-select>
<div class="form-tip" v-if="credentials.length === 0">
暂无凭证,请先在 <router-link to="/credentials">DNS 凭证</router-link> 中添加
</div>
</el-form-item>
</template>
<!-- Manual input -->
<template v-if="credMode === 'manual'">
<el-form-item label="DNS 提供商" prop="dns_provider">
<el-select v-model="form.dns_provider" placeholder="选择 DNS 提供商" style="width: 100%" @change="onProviderChange">
<el-option label="阿里云 DNS (Aliyun)" value="alidns" /> <el-option label="阿里云 DNS (Aliyun)" value="alidns" />
<el-option label="Cloudflare" value="cloudflare" /> <el-option label="Cloudflare" value="cloudflare" />
<el-option label="DNSPod" value="dnspod" /> <el-option label="DNSPod" value="dnspod" />
@@ -41,32 +69,42 @@
</el-form-item> </el-form-item>
<!-- Aliyun DNS Config --> <!-- Aliyun DNS Config -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'alidns'"> <template v-if="form.dns_provider === 'alidns'">
<el-form-item label="AccessKey ID" prop="ali_key"> <el-form-item label="AccessKey ID">
<el-input v-model="dnsConfig.ali_key" placeholder="Aliyun AccessKey ID" /> <el-input v-model="manualConfig.ali_key" placeholder="Aliyun AccessKey ID" />
</el-form-item> </el-form-item>
<el-form-item label="AccessKey Secret" prop="ali_secret"> <el-form-item label="AccessKey Secret">
<el-input v-model="dnsConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" /> <el-input v-model="manualConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" />
</el-form-item> </el-form-item>
</template> </template>
<!-- Cloudflare Config --> <!-- Cloudflare Config -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'cloudflare'"> <template v-if="form.dns_provider === 'cloudflare'">
<el-form-item label="API Token"> <el-form-item label="API Token">
<el-input v-model="dnsConfig.cf_api_token" type="password" show-password placeholder="Cloudflare API Token" /> <el-input v-model="manualConfig.cf_api_token" type="password" show-password placeholder="Cloudflare API Token" />
</el-form-item> </el-form-item>
</template> </template>
<!-- DNSPod Config --> <!-- DNSPod Config -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'dnspod'"> <template v-if="form.dns_provider === 'dnspod'">
<el-form-item label="ID"> <el-form-item label="ID">
<el-input v-model="dnsConfig.dnspod_id" placeholder="DNSPod ID" /> <el-input v-model="manualConfig.dnspod_id" placeholder="DNSPod ID" />
</el-form-item> </el-form-item>
<el-form-item label="Key"> <el-form-item label="Key">
<el-input v-model="dnsConfig.dnspod_key" type="password" show-password placeholder="DNSPod Key" /> <el-input v-model="manualConfig.dnspod_key" type="password" show-password placeholder="DNSPod Key" />
</el-form-item> </el-form-item>
</template> </template>
<!-- Save as credential option -->
<el-form-item v-if="form.dns_provider">
<el-checkbox v-model="saveAsCredential">保存为新凭证</el-checkbox>
</el-form-item>
<el-form-item v-if="saveAsCredential && form.dns_provider" label="凭证名称">
<el-input v-model="newCredName" placeholder="例如:阿里云生产环境" />
</el-form-item>
</template>
</template>
<el-form-item label="自动续期"> <el-form-item label="自动续期">
<el-switch v-model="form.auto_renew" /> <el-switch v-model="form.auto_renew" />
</el-form-item> </el-form-item>
@@ -87,14 +125,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { certApi } from '../api' import { certApi, credApi } from '../api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
interface Credential {
id: number
name: string
type: string
masked: string
}
const router = useRouter() const router = useRouter()
const formRef = ref() const formRef = ref()
const submitting = ref(false) const submitting = ref(false)
const credentials = ref<Credential[]>([])
// Credential mode: select existing or manual input
const credMode = ref<'credential' | 'manual'>('credential')
const selectedCredId = ref<number | null>(null)
const saveAsCredential = ref(false)
const newCredName = ref('')
const form = reactive({ const form = reactive({
domain: '', domain: '',
@@ -106,7 +158,7 @@ const form = reactive({
renew_days: 30, renew_days: 30,
}) })
const dnsConfig = reactive({ const manualConfig = reactive({
ali_key: '', ali_key: '',
ali_secret: '', ali_secret: '',
cf_api_token: '', cf_api_token: '',
@@ -122,15 +174,92 @@ const rules = {
], ],
} }
const typeLabel = (type: string) => {
const labels: Record<string, string> = {
alidns: '阿里云 DNS',
cloudflare: 'Cloudflare',
dnspod: 'DNSPod',
}
return labels[type] || type
}
const loadCredentials = async () => {
try {
const res = await credApi.list()
credentials.value = res.data
} catch {
// Ignore errors loading credentials
}
}
const onChallengeChange = () => { const onChallengeChange = () => {
// Reset DNS config when switching // Reset DNS config when switching challenge type
Object.keys(dnsConfig).forEach(k => (dnsConfig as any)[k] = '') Object.keys(manualConfig).forEach(k => (manualConfig as any)[k] = '')
selectedCredId.value = null
credMode.value = 'credential'
saveAsCredential.value = false
newCredName.value = ''
}
const onProviderChange = () => {
Object.keys(manualConfig).forEach(k => (manualConfig as any)[k] = '')
}
const onCredSelected = (credId: number) => {
// Just store the selection, backend resolves DNS config
}
const buildDnsConfigJson = (): string => {
switch (form.dns_provider) {
case 'alidns':
return JSON.stringify({
ali_key: manualConfig.ali_key,
ali_secret: manualConfig.ali_secret,
})
case 'cloudflare':
return JSON.stringify({
cf_api_token: manualConfig.cf_api_token,
})
case 'dnspod':
return JSON.stringify({
dnspod_id: manualConfig.dnspod_id,
dnspod_key: manualConfig.dnspod_key,
})
default:
return '{}'
}
} }
const handleSubmit = async () => { const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false) const valid = await formRef.value.validate().catch(() => false)
if (!valid) return if (!valid) return
// Validate manual fields
if (form.challenge_type === 'dns' && credMode.value === 'manual') {
if (!form.dns_provider) {
ElMessage.warning('请选择 DNS 提供商')
return
}
// Check required fields
if (form.dns_provider === 'alidns' && (!manualConfig.ali_key || !manualConfig.ali_secret)) {
ElMessage.warning('请填写阿里云 AccessKey')
return
}
if (form.dns_provider === 'cloudflare' && !manualConfig.cf_api_token) {
ElMessage.warning('请填写 Cloudflare API Token')
return
}
if (form.dns_provider === 'dnspod' && (!manualConfig.dnspod_id || !manualConfig.dnspod_key)) {
ElMessage.warning('请填写 DNSPod 凭证')
return
}
if (saveAsCredential.value && !newCredName.value) {
ElMessage.warning('请输入凭证名称')
return
}
}
submitting.value = true submitting.value = true
try { try {
const payload: any = { const payload: any = {
@@ -143,12 +272,31 @@ const handleSubmit = async () => {
} }
if (form.challenge_type === 'dns') { if (form.challenge_type === 'dns') {
if (credMode.value === 'credential' && selectedCredId.value) {
// Use existing credential
payload.credential_id = selectedCredId.value
} else if (credMode.value === 'manual') {
// Manual input
payload.dns_provider = form.dns_provider payload.dns_provider = form.dns_provider
payload.dns_config = JSON.stringify(dnsConfig) payload.dns_config = buildDnsConfigJson()
// Optionally save as new credential
if (saveAsCredential.value && newCredName.value) {
await credApi.create({
name: newCredName.value,
type: form.dns_provider,
data: payload.dns_config,
})
}
} else {
ElMessage.warning('请选择 DNS 凭证或选择手动输入')
submitting.value = false
return
}
} }
await certApi.create(payload) await certApi.create(payload)
ElMessage.success(`证书申请已提交后台处理中`) ElMessage.success('证书申请已提交后台处理中')
router.push('/certificates') router.push('/certificates')
} catch (e: any) { } catch (e: any) {
ElMessage.error('提交失败: ' + (e.response?.data?.error || e.message)) ElMessage.error('提交失败: ' + (e.response?.data?.error || e.message))
@@ -156,6 +304,8 @@ const handleSubmit = async () => {
submitting.value = false submitting.value = false
} }
} }
onMounted(loadCredentials)
</script> </script>
<style scoped> <style scoped>
@@ -164,4 +314,19 @@ const handleSubmit = async () => {
font-size: 24px; font-size: 24px;
color: #1a1a2e; color: #1a1a2e;
} }
.form-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.form-tip a {
color: #1890ff;
text-decoration: none;
}
.form-tip a:hover {
text-decoration: underline;
}
</style> </style>
+246
View File
@@ -0,0 +1,246 @@
<template>
<div class="credentials-page">
<div class="page-header">
<h2 class="page-title">DNS 凭证管理</h2>
<el-button type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon> 新增凭证
</el-button>
</div>
<el-table :data="credentials" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="类型" width="200">
<template #default="{ row }">
<el-tag :type="row.type === 'alidns' ? 'success' : row.type === 'cloudflare' ? 'warning' : 'info'">
{{ typeLabel(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="密钥预览" min-width="250">
<template #default="{ row }">
<code style="font-size: 12px; color: #999">{{ row.masked || '{}' }}</code>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Create / Edit Dialog -->
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑凭证' : '新增凭证'"
width="550px"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="例如:阿里云生产环境" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="选择 DNS 提供商" @change="onTypeChange" style="width: 100%">
<el-option
v-for="t in credTypes"
:key="t.type"
:label="t.label"
:value="t.type"
/>
</el-select>
</el-form-item>
<template v-if="form.type && currentFields.length > 0">
<div class="cred-fields-title">密钥配置</div>
<el-form-item
v-for="field in currentFields"
:key="field.key"
:label="field.label"
:prop="'data.' + field.key"
:rules="field.required ? [{ required: true, message: '请输入' + field.label, trigger: 'blur' }] : []"
>
<el-input
v-model="form.data[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:show-password="field.type === 'password'"
:placeholder="'请输入' + field.label"
/>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { credApi, type Credential, type CredentialType } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const credentials = ref<Credential[]>([])
const credTypes = ref<CredentialType[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const formRef = ref()
const form = reactive({
name: '',
type: '',
data: {} as Record<string, string>,
})
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
}
const currentFields = computed(() => {
const ct = credTypes.value.find(t => t.type === form.type)
return ct?.fields || []
})
const typeLabel = (type: string) => {
const ct = credTypes.value.find(t => t.type === type)
return ct?.label || type
}
const formatTime = (t: string) => {
if (!t) return ''
return t.replace('T', ' ').substring(0, 19)
}
const loadData = async () => {
loading.value = true
try {
const [credRes, typesRes] = await Promise.all([
credApi.list(),
credApi.types(),
])
credentials.value = credRes.data
credTypes.value = typesRes.data
} catch (e: any) {
ElMessage.error('加载失败: ' + (e.response?.data?.error || e.message))
} finally {
loading.value = false
}
}
const showCreateDialog = () => {
editingId.value = null
form.name = ''
form.type = ''
form.data = {}
dialogVisible.value = true
}
const showEditDialog = (cred: Credential) => {
editingId.value = cred.id
form.name = cred.name
form.type = cred.type
// Parse masked data for display (secrets not returned by API)
try {
const parsed = JSON.parse(cred.masked || '{}')
form.data = parsed
} catch {
form.data = {}
}
dialogVisible.value = true
}
const onTypeChange = () => {
form.data = {}
}
const handleSave = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const dataPayload = JSON.stringify(form.data)
if (editingId.value) {
await credApi.update(editingId.value, {
name: form.name,
data: dataPayload,
})
ElMessage.success('凭证已更新')
} else {
await credApi.create({
name: form.name,
type: form.type,
data: dataPayload,
})
ElMessage.success('凭证已创建')
}
dialogVisible.value = false
await loadData()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.error || e.message))
} finally {
saving.value = false
}
}
const handleDelete = async (cred: Credential) => {
try {
await ElMessageBox.confirm(`确定删除凭证「${cred.name}」?`, '确认删除', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await credApi.delete(cred.id)
ElMessage.success('凭证已删除')
await loadData()
} catch {
// cancelled
}
}
onMounted(loadData)
</script>
<style scoped>
.credentials-page {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
color: #1a1a2e;
margin: 0;
}
.cred-fields-title {
font-weight: 600;
color: #333;
padding: 12px 0 8px;
border-top: 1px solid #eee;
margin-bottom: 8px;
}
:deep(.el-table code) {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
}
</style>