完善系统设置DNS验证保存

This commit is contained in:
xuans 2026-08-30 23:08:09 +08:00
parent 4a46512771
commit f1c7fc34bd
3 changed files with 440 additions and 8 deletions

View File

@ -1,6 +1,7 @@
package httpapi
import (
"context"
"errors"
"net/http"
"reflect"
@ -51,8 +52,9 @@ type settingsACMEView struct {
}
type settingsDNSView struct {
Provider string `json:"provider"`
VerificationState string `json:"verification_state"`
Provider string `json:"provider"`
VerificationState string `json:"verification_state"`
CredentialConfigured bool `json:"credential_configured"`
}
type settingsNotificationView struct {
@ -122,7 +124,10 @@ type settingsACMERequest struct {
}
type settingsDNSRequest struct {
Provider *string `json:"provider"`
Provider *string `json:"provider"`
CredentialRef *string `json:"credential_ref"`
VerificationToken *string `json:"verification_token"`
TestDomain *string `json:"test_domain"`
}
type settingsNotificationRequest struct {
@ -182,6 +187,19 @@ func (s *Server) updateSettings(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "保存通知凭据失败", err)
return
}
dnsCredentialRef, dnsVerificationToken, dnsTestDomain, err := s.applySettingsDNS(c.Request.Context(), &updated, previous, request.DNS)
if err != nil {
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef {
s.rollbackSetupCredentialsWithLog([]string{dnsCredentialRef})
}
s.configMu.Unlock()
s.writeError(c, http.StatusBadRequest, "保存 DNS 设置失败", err)
return
}
if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef {
createdCredentialRefs = append(createdCredentialRefs, dnsCredentialRef)
}
if err := updated.Validate(); err != nil {
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
s.configMu.Unlock()
@ -208,10 +226,12 @@ func (s *Server) updateSettings(c *gin.Context) {
s.config = updated
s.configMu.Unlock()
oldCredentialRefs := []string{
previous.DNS.CredentialRef,
previous.Notification.HTTPCredentialRef,
previous.Notification.SMTPCredentialRef,
}
newCredentialRefs := map[string]struct{}{
updated.DNS.CredentialRef: {},
updated.Notification.HTTPCredentialRef: {},
updated.Notification.SMTPCredentialRef: {},
}
@ -227,7 +247,12 @@ func (s *Server) updateSettings(c *gin.Context) {
}
if len(obsoleteCredentialRefs) > 0 {
if err := s.rollbackSetupCredentials(obsoleteCredentialRefs); err != nil && s.logger != nil {
s.logger.Warn("\u6e05\u7406\u5df2\u66ff\u6362\u7684\u901a\u77e5\u654f\u611f\u51ed\u636e\u5931\u8d25", "error", err)
s.logger.Warn("\u6e05\u7406\u5df2\u66ff\u6362\u7684\u654f\u611f\u51ed\u636e\u5931\u8d25", "error", err)
}
}
if dnsVerificationToken != "" && s.setupDNS != nil {
if err := s.setupDNS.Consume(dnsVerificationToken, updated.DNS.Provider, dnsCredentialRef, dnsTestDomain); err != nil && s.logger != nil {
s.logger.Warn("系统设置已保存,但清理 DNS 验证令牌失败", "error", err)
}
}
@ -238,6 +263,39 @@ func (s *Server) updateSettings(c *gin.Context) {
})
}
func (s *Server) applySettingsDNS(ctx context.Context, cfg *config.Config, previous config.Config, request *settingsDNSRequest) (string, string, string, error) {
if cfg == nil || request == nil {
return "", "", "", nil
}
providerChanged := cfg.DNS.Provider != strings.TrimSpace(previous.DNS.Provider)
hasVerificationInput := request.CredentialRef != nil || request.VerificationToken != nil || request.TestDomain != nil
if !hasVerificationInput {
if providerChanged {
cfg.DNS.CredentialRef = ""
cfg.DNS.VerificationState = "pending"
}
return "", "", "", nil
}
if s.setupDNS == nil {
return "", "", "", errors.New("DNS 在线验证服务尚未初始化")
}
if request.CredentialRef == nil || request.VerificationToken == nil || request.TestDomain == nil {
return "", "", "", errors.New("DNS 验证参数不完整,请先完成在线测试")
}
credentialRef := strings.TrimSpace(*request.CredentialRef)
verificationToken := strings.TrimSpace(*request.VerificationToken)
testDomain := strings.TrimSpace(*request.TestDomain)
if credentialRef == "" || verificationToken == "" || testDomain == "" {
return credentialRef, verificationToken, testDomain, errors.New("DNS 验证参数不完整,请先完成在线测试")
}
if err := s.setupDNS.Check(verificationToken, cfg.DNS.Provider, credentialRef, testDomain); err != nil {
return credentialRef, verificationToken, testDomain, err
}
cfg.DNS.CredentialRef = credentialRef
cfg.DNS.VerificationState = "verified"
return credentialRef, verificationToken, testDomain, nil
}
func settingsViewFromConfig(cfg config.Config) settingsView {
return settingsView{
Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode},
@ -250,7 +308,10 @@ func settingsViewFromConfig(cfg config.Config) settingsView {
RenewalMaxRetries: cfg.ACME.RenewalMaxRetries, RenewalRetryIntervalMins: cfg.ACME.RenewalRetryIntervalMins,
PropagationTimeoutSecs: cfg.ACME.PropagationTimeoutSecs, PropagationPollingIntervalSecs: cfg.ACME.PropagationPollingIntervalSecs,
},
DNS: settingsDNSView{Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState},
DNS: settingsDNSView{
Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState,
CredentialConfigured: strings.TrimSpace(cfg.DNS.CredentialRef) != "",
},
Notification: settingsNotificationView{
HTTPEnabled: cfg.Notification.HTTPEnabled, SMTPEnabled: cfg.Notification.SMTPEnabled, Provider: cfg.Notification.Provider,
SMTPHost: cfg.Notification.SMTPHost, SMTPPort: cfg.Notification.SMTPPort, SMTPUsername: cfg.Notification.SMTPUsername,

View File

@ -3,8 +3,10 @@ package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
@ -12,6 +14,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
func TestSettingsAPIRequiresAuthentication(t *testing.T) {
@ -439,3 +442,238 @@ func TestSettingsAPIRollsBackEarlierCredentialWhenLaterCredentialFails(t *testin
t.Fatalf("earlier HTTP credentials should be rolled back: values=%v deleted=%v", secrets.values, secrets.deleted)
}
}
func TestSettingsAPIDNSRequiresCompleteVerification(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
cfg.Web.Hostname = "scert.example.test"
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
server := New(Dependencies{
Config: cfg, ConfigPath: configPath,
SetupDNS: &fakeSetupDNSVerifier{},
})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {"provider": "cloudflare", "verification_token": "token-only"}
}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("DNS 验证参数不完整应返回 400,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "保存 DNS 设置失败") {
t.Fatalf("错误响应应说明 DNS 设置保存失败:%s", response.Body.String())
}
}
func TestSettingsAPIDNSProviderChangeRequiresReverification(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
cfg.Web.Hostname = "scert.example.test"
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
server := New(Dependencies{Config: cfg, ConfigPath: configPath})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {"provider": "aliyun"}
}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("更换 DNS 服务商但未重新验证应返回 400,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "设置内容校验失败") {
t.Fatalf("错误响应应说明设置校验失败:%s", response.Body.String())
}
}
func TestSettingsAPISavesVerifiedDNSCredentialWithoutReturningSecret(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
cfg.Web.Hostname = "scert.example.test"
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
const newCredentialRef = "secret://dns/cloudflare/new"
verifier := &fakeSetupDNSVerifier{verifyResult: setup.DNSVerificationResult{
CredentialRef: newCredentialRef, VerificationToken: "new-verification-token",
}}
secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{
cfg.DNS.CredentialRef: {"api_token": "old-token"},
newCredentialRef: {"api_token": "new-token"},
}}
server := New(Dependencies{Config: cfg, ConfigPath: configPath, SetupDNS: verifier, Secrets: secrets})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {
"provider": "cloudflare",
"credential_ref": "secret://dns/cloudflare/new",
"verification_token": "new-verification-token",
"test_domain": "example.com"
}
}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("保存已验证 DNS 设置应成功,实际为 %d:%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, secret := range []string{"new-verification-token", newCredentialRef, "new-token", "credential_ref"} {
if strings.Contains(body, secret) {
t.Fatalf("设置响应不应返回 DNS 敏感内容 %q:%s", secret, body)
}
}
if !verifier.consumed {
t.Fatal("保存成功后应消费 DNS 验证令牌")
}
if _, ok := secrets.values[cfg.DNS.CredentialRef]; ok {
t.Fatal("替换后的旧 DNS 凭据应被清理")
}
if _, ok := secrets.values[newCredentialRef]; !ok {
t.Fatal("新的 DNS 凭据应继续保留")
}
saved, err := config.Load(configPath)
if err != nil {
t.Fatalf("读取保存后的配置失败:%v", err)
}
if saved.DNS.CredentialRef != newCredentialRef || saved.DNS.VerificationState != "verified" {
t.Fatalf("DNS 配置没有正确持久化:%+v", saved.DNS)
}
}
func TestSettingsAPIDNSCheckFailureRollsBackCredential(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
cfg.Web.Hostname = "scert.example.test"
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
const newCredentialRef = "secret://dns/cloudflare/new"
verifier := &fakeSetupDNSVerifier{checkErr: errors.New("DNS 验证令牌无效")}
secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{
newCredentialRef: {"api_token": "new-token"},
}}
server := New(Dependencies{Config: cfg, ConfigPath: configPath, SetupDNS: verifier, Secrets: secrets})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {
"provider": "cloudflare",
"credential_ref": "secret://dns/cloudflare/new",
"verification_token": "invalid-token",
"test_domain": "example.com"
}
}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("无效 DNS 验证令牌应返回 400,实际为 %d:%s", response.Code, response.Body.String())
}
if _, ok := secrets.values[newCredentialRef]; ok {
t.Fatal("DNS 验证失败后不应保留新凭据")
}
if len(secrets.deleted) != 1 || secrets.deleted[0] != newCredentialRef {
t.Fatalf("DNS 验证失败后应删除新凭据:%v", secrets.deleted)
}
}
func TestSettingsAPIDNSSaveFailureRollsBackCredential(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg.SetupReady = true
cfg.DNS.VerificationState = "verified"
cfg.DNS.Provider = "cloudflare"
cfg.DNS.CredentialRef = "secret://dns/cloudflare/old"
cfg.Web.Hostname = "scert.example.test"
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
initialConfigPath := dir + "\\config\\initial.json"
if err := cfg.Save(initialConfigPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
blockedPath := dir + "\\blocked"
if err := os.WriteFile(blockedPath, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("创建保存失败测试文件失败:%v", err)
}
const newCredentialRef = "secret://dns/cloudflare/new"
verifier := &fakeSetupDNSVerifier{verifyResult: setup.DNSVerificationResult{
CredentialRef: newCredentialRef, VerificationToken: "new-verification-token",
}}
secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{
newCredentialRef: {"api_token": "new-token"},
}}
server := New(Dependencies{Config: cfg, ConfigPath: blockedPath + "\\scert.json", SetupDNS: verifier, Secrets: secrets})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{
"dns": {
"provider": "cloudflare",
"credential_ref": "secret://dns/cloudflare/new",
"verification_token": "new-verification-token",
"test_domain": "example.com"
}
}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusInternalServerError {
t.Fatalf("配置保存失败应返回 500,实际为 %d:%s", response.Code, response.Body.String())
}
if _, ok := secrets.values[newCredentialRef]; ok {
t.Fatal("配置保存失败后不应保留新 DNS 凭据")
}
if len(secrets.deleted) != 1 || secrets.deleted[0] != newCredentialRef {
t.Fatalf("配置保存失败后应删除新 DNS 凭据:%v", secrets.deleted)
}
if verifier.consumed {
t.Fatal("配置保存失败后不应消费 DNS 验证令牌")
}
}

View File

@ -11,6 +11,7 @@ export function useDashboard() {
const tasks = ref([]);
const auditLogs = ref([]);
const settings = ref(null);
const dnsProviderCatalog = ref([]);
const domainServers = ref({});
const loading = ref(true);
const saving = ref(false);
@ -34,6 +35,8 @@ export function useDashboard() {
const showSettings = ref(false);
const settingsSaving = ref(false);
const settingsError = ref("");
const dnsTesting = ref(false);
const dnsTestError = ref("");
const settingsForm = ref(createEmptySettingsForm());
const showCertificateImport = ref(false);
const importDomain = ref(null);
@ -184,6 +187,27 @@ export function useDashboard() {
const formTitle = computed(() =>
isEditing.value ? "编辑域名配置" : "添加托管域名",
);
const selectedDNSProvider = computed(() =>
dnsProviderCatalog.value.find(
(provider) => provider.id === settingsForm.value.dns.provider,
) || null,
);
const dnsVerificationRequired = computed(() => {
const current = settingsForm.value.dns;
const saved = settings.value?.dns;
if (!current.provider) return false;
return (
current.provider !== saved?.provider ||
!saved?.credential_configured ||
current.verification_state === "pending"
);
});
const dnsReadyToSave = computed(
() =>
!dnsVerificationRequired.value ||
(Boolean(settingsForm.value.dns.credential_ref) &&
Boolean(settingsForm.value.dns.verification_token)),
);
const defaultCertificatePath = "/ssl/{域名}/{证书类型}/cert.pem";
const defaultCertificateChainPath = "/ssl/{域名}/{证书类型}/fullchain.pem";
@ -304,7 +328,16 @@ export function useDashboard() {
propagation_timeout_secs: 900,
propagation_polling_interval_secs: 10,
},
dns: { provider: "" },
dns: {
provider: "",
credential_configured: false,
verification_state: "not_configured",
test_domain: "",
credentials: {},
credential_ref: "",
verification_token: "",
test_report: null,
},
notification: {
http_enabled: false,
smtp_enabled: false,
@ -344,7 +377,15 @@ export function useDashboard() {
web: { ...defaults.web, ...(source.web || {}) },
paths: { ...defaults.paths, ...(source.paths || {}) },
acme: { ...defaults.acme, ...(source.acme || {}) },
dns: { ...defaults.dns, ...(source.dns || {}) },
dns: {
...defaults.dns,
...(source.dns || {}),
credentials: {},
credential_ref: "",
verification_token: "",
test_domain: "",
test_report: null,
},
notification: {
...defaults.notification,
...(source.notification || {}),
@ -682,8 +723,84 @@ export function useDashboard() {
}
}
function handleDNSProviderChange() {
const dns = settingsForm.value.dns;
const saved = settings.value?.dns;
dnsTestError.value = "";
dns.credential_ref = "";
dns.verification_token = "";
dns.test_report = null;
if (dns.provider && dns.provider === saved?.provider && saved.credential_configured) {
dns.verification_state = saved.verification_state || "verified";
return;
}
dns.verification_state = dns.provider ? "pending" : "not_configured";
}
function clearDNSCredentials() {
settingsForm.value.dns.credentials = {};
dnsTestError.value = "";
}
async function testDNS() {
const dns = settingsForm.value.dns;
const provider = selectedDNSProvider.value;
dnsTestError.value = "";
dns.test_report = null;
if (!dns.provider || !provider) {
dnsTestError.value = "请选择 DNS 验证服务商";
return;
}
if (!dns.test_domain.trim()) {
dnsTestError.value = "请输入 DNS 在线测试域名";
return;
}
const credentials = Object.fromEntries(
Object.entries(dns.credentials || {})
.map(([key, value]) => [key, String(value || "").trim()])
.filter(([, value]) => value),
);
const missingField = (provider.credential_fields || []).find(
(field) => field.required && !credentials[field.name],
);
if (missingField) {
dnsTestError.value = `请填写${missingField.label}`;
return;
}
dnsTesting.value = true;
try {
const response = await fetchAdmin("/api/v1/dns/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: dns.provider,
test_domain: dns.test_domain.trim(),
credentials,
}),
});
const result = await response.json().catch(() => ({}));
dns.test_report = result.test_report || null;
if (!response.ok) {
throw new Error(result.error || "DNS API 在线测试失败");
}
dns.credential_ref = result.credential_ref || "";
dns.verification_token = result.verification_token || "";
dns.verification_state = "verified";
dns.credential_configured = true;
notice.value = "DNS API 在线测试通过,可以保存系统设置";
} catch (err) {
dns.credential_ref = "";
dns.verification_token = "";
dns.verification_state = "pending";
dnsTestError.value = err.message || "DNS API 在线测试失败";
} finally {
dnsTesting.value = false;
}
}
function openSettings() {
settingsError.value = "";
dnsTestError.value = "";
settingsForm.value = normalizeSettings(settings.value);
showSettings.value = true;
}
@ -694,12 +811,28 @@ export function useDashboard() {
async function saveSettings() {
settingsError.value = "";
if (!dnsReadyToSave.value) {
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
return;
}
settingsSaving.value = true;
try {
const current = settingsForm.value;
const payload = {
...current,
dns: {
provider: current.dns.provider,
},
};
if (current.dns.credential_ref && current.dns.verification_token) {
payload.dns.credential_ref = current.dns.credential_ref;
payload.dns.verification_token = current.dns.verification_token;
payload.dns.test_domain = current.dns.test_domain;
}
const response = await fetchAdmin("/api/v1/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settingsForm.value),
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "保存系统设置失败");