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
+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>