From e6c40d85a4912103377c82d32b838675796c6762 Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 26 Sep 2026 12:29:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EDNS=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8F=8A=E5=BD=92=E5=B1=9E=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 DNS 账户列表接口、归属用户记录、脱敏账号标识和管理页面导航,补充前端回归覆盖。 --- backend/internal/config/config.go | 1 + backend/internal/httpapi/dns_accounts_api.go | 107 ++++++++++++++ backend/internal/httpapi/server.go | 1 + backend/internal/httpapi/settings_api.go | 11 ++ backend/internal/setup/setup.go | 1 + .../src/views/DNSAccounts/DNSAccounts.css | 137 ++++++++++++++++++ frontend/src/views/DNSAccounts/DNSAccounts.js | 60 ++++++++ .../src/views/DNSAccounts/DNSAccounts.vue | 75 ++++++++++ .../src/views/Dashboard/Dashboard.test.js | 8 +- frontend/src/views/Dashboard/Dashboard.vue | 2 + .../views/Dashboard/DashboardNavigation.js | 3 +- .../views/Dashboard/DashboardNavigation.vue | 5 + 12 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 backend/internal/httpapi/dns_accounts_api.go create mode 100644 frontend/src/views/DNSAccounts/DNSAccounts.css create mode 100644 frontend/src/views/DNSAccounts/DNSAccounts.js create mode 100644 frontend/src/views/DNSAccounts/DNSAccounts.vue diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c9997e2..aa199db 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -90,6 +90,7 @@ type ACMEConfig struct { } type DNSConfig struct { + OwnerUserID string `json:"owner_user_id,omitempty"` Provider string `json:"provider"` CredentialRef string `json:"credential_ref"` VerificationState string `json:"verification_state"` diff --git a/backend/internal/httpapi/dns_accounts_api.go b/backend/internal/httpapi/dns_accounts_api.go new file mode 100644 index 0000000..4a2c66b --- /dev/null +++ b/backend/internal/httpapi/dns_accounts_api.go @@ -0,0 +1,107 @@ +package httpapi + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +type dnsAccountView struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + ProviderName string `json:"provider_name"` + AccountIdentifier string `json:"account_identifier"` + OwnerUserID string `json:"owner_user_id,omitempty"` + OwnerUsername string `json:"owner_username"` + OwnerState string `json:"owner_state"` + VerificationState string `json:"verification_state"` + CredentialState string `json:"credential_state"` +} + +// listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。 +func (s *Server) listDNSAccounts(c *gin.Context) { + c.Header("Cache-Control", "no-store") + s.configMu.RLock() + cfg := s.config + s.configMu.RUnlock() + items := make([]dnsAccountView, 0, 1) + if strings.TrimSpace(cfg.DNS.CredentialRef) == "" { + c.JSON(http.StatusOK, gin.H{"items": items}) + return + } + item := dnsAccountView{ + ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider, + ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识", + OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配", + VerificationState: cfg.DNS.VerificationState, CredentialState: "available", + } + var owner repository.User + if cfg.DNS.OwnerUserID == "primary-super-admin" { + owner = repository.User{ID: cfg.DNS.OwnerUserID, Username: cfg.Admin.Username, Role: repository.UserRoleSuperAdmin} + } else if cfg.DNS.OwnerUserID != "" { + if s.users == nil { + s.writeError(c, http.StatusServiceUnavailable, "用户归属查询暂不可用", nil) + return + } + var err error + owner, err = s.users.GetUserByID(c.Request.Context(), cfg.DNS.OwnerUserID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + s.writeError(c, http.StatusServiceUnavailable, "用户归属查询暂不可用", nil) + return + } + if errors.Is(err, sql.ErrNoRows) { + item.OwnerState, item.OwnerUsername = "missing", "原用户已不存在" + } + } + if owner.ID != "" { + item.OwnerState, item.OwnerUsername = "assigned", owner.Username + } + if descriptor, err := dnsprovider.GetDescriptor(cfg.DNS.Provider); err == nil { + item.ProviderName = descriptor.Name + } + credentials, err := s.readDNSCredentials(c.Request.Context(), cfg.DNS.CredentialRef) + if err != nil { + item.CredentialState, item.AccountIdentifier = "unavailable", "暂不可读取" + } else { + item.AccountIdentifier = dnsAccountIdentifier(cfg.DNS.Provider, credentials) + if _, err := dnsprovider.ValidateCredentials(cfg.DNS.Provider, credentials); err != nil { + item.CredentialState = "invalid" + } + } + items = append(items, item) + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +// dnsAccountIdentifier 只允许输出公开标识的脱敏结果,绝不输出令牌或私钥片段。 +func dnsAccountIdentifier(provider string, credentials map[string]string) string { + var value string + switch provider { + case dnsprovider.Aliyun, dnsprovider.HuaweiCloud, dnsprovider.Route53: + value = credentials["access_key_id"] + case dnsprovider.DNSPod, dnsprovider.TencentCloud: + value = credentials["secret_id"] + case dnsprovider.Cloudflare: + return "令牌账号(已隐藏)" + case dnsprovider.GoogleCloud: + return "服务账号(已隐藏)" + case dnsprovider.CNAME: + return "委派账号(已隐藏)" + default: + return "账号标识已隐藏" + } + identifier := []rune(strings.TrimSpace(value)) + if len(identifier) == 0 { + return "未提供账号标识" + } + if len(identifier) <= 8 { + return "********" + } + return string(identifier[:4]) + "****" + string(identifier[len(identifier)-4:]) +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 0c04056..0a3b392 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -196,6 +196,7 @@ func (s *Server) registerRoutes() { adminAPI.GET("/settings", s.getSettings) adminAPI.PUT("/settings", s.updateSettings) adminAPI.GET("/dns/providers", s.listAdminDNSProviders) + adminAPI.GET("/dns/accounts", s.listDNSAccounts) adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS) adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain) adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow) diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index 6ef8365..5b8177b 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -170,6 +170,11 @@ func (s *Server) getSettings(c *gin.Context) { } func (s *Server) updateSettings(c *gin.Context) { + session, authenticated := s.currentAuthSession(c) + if !authenticated { + s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil) + return + } if strings.TrimSpace(s.configPath) == "" { s.writeError(c, http.StatusInternalServerError, "配置文件路径未配置", errors.New("配置文件路径为空")) return @@ -210,6 +215,12 @@ func (s *Server) updateSettings(c *gin.Context) { if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef { createdCredentialRefs = append(createdCredentialRefs, dnsCredentialRef) } + // 只有实际更换凭据才记录新归属,修改其他设置不能认领历史账号。 + if updated.DNS.CredentialRef == "" { + updated.DNS.OwnerUserID = "" + } else if updated.DNS.CredentialRef != previous.DNS.CredentialRef { + updated.DNS.OwnerUserID = session.UserID + } if err := updated.Validate(); err != nil { s.rollbackSetupCredentialsWithLog(createdCredentialRefs) s.configMu.Unlock() diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index c05e0f3..468146d 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -130,6 +130,7 @@ func Apply(cfg *config.Config, input Input) error { cfg.Paths.Certificate = strings.TrimSpace(input.CertificateRoot) cfg.DNS.Provider = strings.TrimSpace(input.DNSProvider) cfg.DNS.CredentialRef = strings.TrimSpace(input.DNSCredentialRef) + cfg.DNS.OwnerUserID = "primary-super-admin" cfg.DNS.VerificationState = "verified" cfg.Notification.HTTPEnabled = input.EmailHTTPEnabled cfg.Notification.SMTPEnabled = input.EmailSMTPEnabled diff --git a/frontend/src/views/DNSAccounts/DNSAccounts.css b/frontend/src/views/DNSAccounts/DNSAccounts.css new file mode 100644 index 0000000..55fb662 --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccounts.css @@ -0,0 +1,137 @@ +.dns-accounts-panel { + padding: 24px; + border: 1px solid #e2e8f0; + border-radius: 20px; + background: #fff; +} + +.dns-accounts-heading, +.dns-pagination, +.dns-page-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.dns-accounts-heading { + margin-bottom: 24px; +} + +.dns-accounts-heading h2 { + margin: 0 0 8px; + color: #20314f; + font-size: 26px; +} + +.dns-accounts-count, +.dns-muted, +.dns-empty { + color: #64748b; + font-size: 14px; +} + +.dns-refresh, +.dns-pagination button, +.dns-pagination select { + padding: 9px 12px; + border: 1px solid #d5ddea; + border-radius: 10px; + background: #fff; + color: #32405a; + font: inherit; +} + +.dns-refresh, +.dns-pagination button { + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: 0.5; +} + +button:focus-visible, +select:focus-visible, +.dns-table-scroll:focus-visible { + outline: 2px solid #315aaa; + outline-offset: 3px; +} + +.dns-table-scroll { + overflow-x: auto; +} + +.dns-accounts-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 14px; +} + +.dns-accounts-table th, +.dns-accounts-table td { + padding: 16px 12px; + border-bottom: 1px solid #e8edf4; + overflow-wrap: anywhere; +} + +.dns-accounts-table th { + color: #66758d; + background: #f7f9fc; + font-weight: 600; + white-space: nowrap; +} + +.dns-identifier { + font-family: ui-monospace, monospace; +} + +.dns-status { + display: inline-block; + padding: 5px 9px; + border-radius: 7px; + background: #fff4db; + color: #805300; + white-space: nowrap; +} + +.dns-status.verified { + background: #eaf7ef; + color: #216044; +} + +.dns-error { + padding: 14px; + border-radius: 10px; + background: #fff0f0; + color: #a92e2e; +} + +.dns-empty { + margin: 0; + padding: 40px 12px; + text-align: center; +} + +.dns-pagination { + flex-wrap: wrap; + margin-top: 20px; + font-size: 13px; + color: #52627b; +} + +.dns-pagination select { + margin: 0 5px; +} + +@media (max-width: 760px) { + .dns-accounts-panel { + padding: 16px; + } + + .dns-accounts-table { + min-width: 640px; + } +} diff --git a/frontend/src/views/DNSAccounts/DNSAccounts.js b/frontend/src/views/DNSAccounts/DNSAccounts.js new file mode 100644 index 0000000..7186ca8 --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccounts.js @@ -0,0 +1,60 @@ +import { computed, onBeforeUnmount, onMounted, ref } from "vue"; + +export function dnsVerificationLabel(item) { + if (item.credential_state === "unavailable") return "凭据不可读取"; + if (item.credential_state === "invalid") return "凭据配置不完整"; + return item.verification_state === "verified" ? "已验证" : "待验证"; +} + +export function useDNSAccounts() { + const accounts = ref([]); + const loading = ref(false); + const error = ref(""); + const pageSize = ref(10); + const page = ref(1); + const pageCount = computed(() => Math.max(1, Math.ceil(accounts.value.length / pageSize.value))); + const visibleAccounts = computed(() => accounts.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)); + let requestVersion = 0; + let controller; + + async function loadAccounts() { + const version = ++requestVersion; + controller?.abort(); + controller = new AbortController(); + loading.value = true; + error.value = ""; + accounts.value = []; + page.value = 1; + try { + const response = await fetch("/api/v1/dns/accounts", { + credentials: "include", + cache: "no-store", + signal: controller.signal, + }); + if (response.status === 401) throw new Error("登录会话已过期,请重新登录"); + if (response.status === 403) throw new Error("当前账号没有查看 DNS 账户的权限"); + if (!response.ok) throw new Error("DNS 账户加载失败,请稍后重试"); + const data = await response.json(); + if (!Array.isArray(data.items) || !data.items.every((item) => item && typeof item.id === "string")) { + throw new Error("DNS 账户响应格式不正确,请刷新重试"); + } + if (version === requestVersion) accounts.value = data.items; + } catch (reason) { + if (version === requestVersion) error.value = reason.message || "DNS 账户加载失败,请稍后重试"; + } finally { + if (version === requestVersion) loading.value = false; + } + } + + function changePageSize() { + page.value = 1; + } + + onMounted(loadAccounts); + onBeforeUnmount(() => { + requestVersion += 1; + controller?.abort(); + }); + + return { accounts, loading, error, pageSize, page, pageCount, visibleAccounts, loadAccounts, changePageSize }; +} diff --git a/frontend/src/views/DNSAccounts/DNSAccounts.vue b/frontend/src/views/DNSAccounts/DNSAccounts.vue new file mode 100644 index 0000000..ad3c718 --- /dev/null +++ b/frontend/src/views/DNSAccounts/DNSAccounts.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 1e4eb65..b59d13c 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -10,7 +10,7 @@ import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../ test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => { const navigation = useDashboardNavigation(ref({ role: "super_admin" })); assert.equal(navigation.activeSection.value, "overview"); - assert.equal(navigation.sections.value.length, 6); + assert.equal(navigation.sections.value.length, 7); for (const section of navigation.sections.value) { navigation.selectSection(section.id); assert.equal(navigation.activeSection.value, section.id); @@ -31,7 +31,7 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", () assert.equal(navigation.activeSection.value, "users"); user.value = { role: "admin" }; assert.equal(navigation.activeSection.value, "users"); - assert.equal(navigation.sections.value.length, 6); + assert.equal(navigation.sections.value.length, 7); user.value = null; assert.equal(navigation.activeSection.value, "overview"); assert.equal(navigation.sections.value.length, 5); @@ -41,7 +41,7 @@ test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入 const { readFile } = await import("node:fs/promises"); const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8"); const stylesheet = await readFile(new URL("./DashboardLayout.css", import.meta.url), "utf8"); - for (const id of ["overview", "activity", "domains", "servers", "keys", "users"]) { + for (const id of ["overview", "activity", "domains", "servers", "keys", "dns-accounts", "users"]) { assert.ok(template.includes(`v-if="activeSection === '${id}'"`)); } assert.match(template, /:key="activeSection"/); @@ -97,6 +97,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as domains: "托管域名", servers: "部署服务器", keys: "SSH 私钥", + "dns-accounts": "DNS 账户管理", users: "用户管理", }; for (const section of navigation.sections.value) { @@ -109,6 +110,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as app.component(name, { render: () => null }); } app.component("UserManagement", { render: () => Vue.h("h2", headings.users) }); + app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) }); app.component("DeploymentResult", { render: () => null }); const warnings = []; app.config.warnHandler = (message) => warnings.push(message); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index dc72f0f..995717b 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -1,5 +1,6 @@