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/bin/
node_modules/
# IDE
+1
View File
@@ -13,6 +13,7 @@ type Certificate struct {
ChallengeType string `json:"challenge_type"` // http, dns
DNSProvider string `json:"dns_provider,omitempty"` // alidns, cloudflare, etc.
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
CertURL string `json:"cert_url,omitempty"`
+1
View File
@@ -76,6 +76,7 @@ func InitStore(cfg *Config) {
func InitAll(cfg *Config) {
InitStore(cfg)
InitNotifyStore(cfg)
InitCredentialStore(cfg)
}
// 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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoSSL 证书管理</title>
<script type="module" crossorigin src="/assets/index-D6V1aiIx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Kwmxr6vY.css">
<script type="module" crossorigin src="/assets/index-e3s7Ic6B.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BDJKlQw8.css">
</head>
<body>
<div id="app"></div>
+21
View File
@@ -26,6 +26,7 @@ type CreateCertRequest struct {
ChallengeType string `json:"challenge_type"`
DNSProvider string `json:"dns_provider"`
DNSConfig string `json:"dns_config"`
CredentialID *uint `json:"credential_id"`
AutoRenew *bool `json:"auto_renew"`
RenewDays *int `json:"renew_days"`
}
@@ -51,6 +52,19 @@ func (h *CertHandler) GetCertificate(c *gin.Context) {
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
func (h *CertHandler) CreateCertificate(c *gin.Context) {
var req CreateCertRequest
@@ -68,6 +82,12 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
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
renewDays := 30
if req.AutoRenew != nil {
@@ -120,6 +140,7 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
ChallengeType: req.ChallengeType,
DNSProvider: req.DNSProvider,
DNSConfig: req.DNSConfig,
CredentialID: req.CredentialID,
Status: "pending",
AutoRenew: autoRenew,
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())
{
certHandler := handlers.NewCertHandler(cfg)
credHandler := handlers.NewCredentialHandler()
// Certificate management
api.GET("/certificates", certHandler.ListCertificates)
@@ -69,6 +70,14 @@ func main() {
api.POST("/notify/test-feishu", handleTestFeishu)
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
api.GET("/renewals/check", certHandler.CheckRenewals)
api.GET("/stats", certHandler.Stats)
+4
View File
@@ -53,6 +53,10 @@
<el-icon><Bell /></el-icon>
<span>通知配置</span>
</el-menu-item>
<el-menu-item index="/credentials">
<el-icon><Key /></el-icon>
<span>DNS 凭证</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
+33
View File
@@ -91,6 +91,29 @@ export interface NotifyConfig {
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
export const authApi = {
login: (username: string, password: string) =>
@@ -136,3 +159,13 @@ export const notifyApi = {
testFeishu: () => api.post('/notify/test-feishu'),
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 CertCreate from '../views/CertCreate.vue'
import NotifyConfig from '../views/NotifyConfig.vue'
import Credentials from '../views/Credentials.vue'
import Login from '../views/Login.vue'
import { isLoggedIn } from '../api'
@@ -39,6 +40,12 @@ const router = createRouter({
component: NotifyConfig,
meta: { requiresAuth: true },
},
{
path: '/credentials',
name: 'Credentials',
component: Credentials,
meta: { requiresAuth: true },
},
],
})
+185 -20
View File
@@ -8,7 +8,7 @@
:model="form"
:rules="rules"
label-width="140px"
style="max-width: 600px"
style="max-width: 620px"
>
<el-form-item label="域名" prop="domain">
<el-input v-model="form.domain" placeholder="example.com" />
@@ -32,8 +32,36 @@
</el-radio-group>
</el-form-item>
<el-form-item label="DNS 提供商" v-if="form.challenge_type === 'dns'" prop="dns_provider">
<el-select v-model="form.dns_provider" placeholder="选择 DNS 提供商" style="width: 100%">
<!-- DNS mode: credential or manual -->
<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="Cloudflare" value="cloudflare" />
<el-option label="DNSPod" value="dnspod" />
@@ -41,32 +69,42 @@
</el-form-item>
<!-- Aliyun DNS Config -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'alidns'">
<el-form-item label="AccessKey ID" prop="ali_key">
<el-input v-model="dnsConfig.ali_key" placeholder="Aliyun AccessKey ID" />
<template v-if="form.dns_provider === 'alidns'">
<el-form-item label="AccessKey ID">
<el-input v-model="manualConfig.ali_key" placeholder="Aliyun AccessKey ID" />
</el-form-item>
<el-form-item label="AccessKey Secret" prop="ali_secret">
<el-input v-model="dnsConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" />
<el-form-item label="AccessKey Secret">
<el-input v-model="manualConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" />
</el-form-item>
</template>
<!-- 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-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>
</template>
<!-- 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-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 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>
</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-switch v-model="form.auto_renew" />
</el-form-item>
@@ -87,14 +125,28 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { certApi } from '../api'
import { ref, reactive, onMounted } from 'vue'
import { certApi, credApi } from '../api'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
interface Credential {
id: number
name: string
type: string
masked: string
}
const router = useRouter()
const formRef = ref()
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({
domain: '',
@@ -106,7 +158,7 @@ const form = reactive({
renew_days: 30,
})
const dnsConfig = reactive({
const manualConfig = reactive({
ali_key: '',
ali_secret: '',
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 = () => {
// Reset DNS config when switching
Object.keys(dnsConfig).forEach(k => (dnsConfig as any)[k] = '')
// Reset DNS config when switching challenge type
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 valid = await formRef.value.validate().catch(() => false)
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
try {
const payload: any = {
@@ -143,12 +272,31 @@ const handleSubmit = async () => {
}
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_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)
ElMessage.success(`证书申请已提交后台处理中`)
ElMessage.success('证书申请已提交后台处理中')
router.push('/certificates')
} catch (e: any) {
ElMessage.error('提交失败: ' + (e.response?.data?.error || e.message))
@@ -156,6 +304,8 @@ const handleSubmit = async () => {
submitting.value = false
}
}
onMounted(loadCredentials)
</script>
<style scoped>
@@ -164,4 +314,19 @@ const handleSubmit = async () => {
font-size: 24px;
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>
+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>