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
+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 },
},
],
})
+204 -39
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,39 +32,77 @@
</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%">
<el-option label="阿里云 DNS (Aliyun)" value="alidns" />
<el-option label="Cloudflare" value="cloudflare" />
<el-option label="DNSPod" value="dnspod" />
</el-select>
</el-form-item>
<!-- 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>
<!-- 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" />
</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>
</template>
<!-- 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>
<!-- Cloudflare Config -->
<template v-if="form.challenge_type === 'dns' && 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-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" />
</el-select>
</el-form-item>
<!-- DNSPod Config -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'dnspod'">
<el-form-item label="ID">
<el-input v-model="dnsConfig.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-form-item>
<!-- Aliyun DNS Config -->
<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">
<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.dns_provider === 'cloudflare'">
<el-form-item label="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.dns_provider === 'dnspod'">
<el-form-item label="ID">
<el-input v-model="manualConfig.dnspod_id" placeholder="DNSPod ID" />
</el-form-item>
<el-form-item label="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="自动续期">
@@ -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') {
payload.dns_provider = form.dns_provider
payload.dns_config = JSON.stringify(dnsConfig)
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 = 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>