Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec b9f430de16 refactor(acme-cloudflare): enhance upsertARecord and syncCertificateDomainRecords functions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 2m21s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Updated the upsertARecord function to return status messages ("updated", "created", "skipped_cname") instead of void, improving clarity on record handling. Modified syncCertificateDomainRecords to collect and return skipped CNAME records, enhancing error handling and feedback during DNS operations.
2026-09-05 01:29:58 +07:00
Denozordec 25e040a5dd fix(settings): improve error handling and success notifications in EvoBGP settings
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m26s
Docker images / frontend-image (push) Successful in 2m26s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Enhanced the error handling in the settings page by introducing a dedicated error message function. Added success and error toast notifications for better user feedback during settings save operations. Updated API key normalization to ensure consistent handling across the application.
2026-09-05 00:07:58 +07:00
4 changed files with 109 additions and 74 deletions
+4 -1
View File
@@ -875,8 +875,11 @@ export default function SettingsPage() {
await evo.saveSettings(patch) await evo.saveSettings(patch)
setEvoKeyDraft("") setEvoKeyDraft("")
markSaved() markSaved()
toast.success("Настройки EvoBGP сохранены")
} catch (e) { } catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения") const msg = e instanceof Error ? e.message : "Ошибка сохранения"
setEvoSaveErr(msg)
toast.error(msg)
} finally { } finally {
setEvoSaveBusy(false) setEvoSaveBusy(false)
} }
+19 -8
View File
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
} }
} }
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
function normalizeApiKey(raw: string): string {
const trimmed = raw.trim()
return trimmed.replace(/^Bearer\s+/i, "").trim()
}
interface EvoCatalogRaw { interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> } modules: { items: Array<{ id: string; name: string; type: string }> }
domains: { domains: {
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
function credentialsFromDb(): { root: string; apiKey: string } | null { function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl) const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim() const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null if (!root || !apiKey) return null
return { root, apiKey } return { root, apiKey }
} }
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false, enabled: Boolean(row.enabled),
secretConfigured: Boolean(row.apiKey?.trim()), secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
}) })
}) })
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
let nextEnabled = cur.enabled let nextEnabled = cur.enabled
let nextKey = cur.apiKey let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim() if (parsed.data.baseUrl !== undefined) {
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
}
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) { if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim() nextKey =
parsed.data.apiKey === null || parsed.data.apiKey === ""
? ""
: normalizeApiKey(parsed.data.apiKey)
} }
db.update(evobgpSettings) db.update(evobgpSettings)
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false, enabled: Boolean(row.enabled),
secretConfigured: Boolean(row.apiKey?.trim()), secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
}) })
}) })
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const keyRaw = const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim()) const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim() const token = normalizeApiKey(keyRaw)
if (!root || !token) { if (!root || !token) {
return reply.status(400).send({ return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)", error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
+30 -8
View File
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
) )
} }
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> { async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
const records = await listDnsRecordsByName(token, zoneId, fqdn) const records = await listDnsRecordsByName(token, zoneId, fqdn)
const existingA = records.find((record) => record.type === "A") const existingA = records.find((record) => record.type === "A")
if (existingA) { if (existingA) {
if (existingA.content === ip) return if (existingA.content === ip) return "updated"
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, { await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false, proxied: false,
}), }),
}) })
return return "updated"
} }
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
if (records.some((record) => record.type === "CNAME")) { if (records.some((record) => record.type === "CNAME")) {
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`) return "skipped_cname"
} }
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, { await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false, proxied: false,
}), }),
}) })
return "created"
} }
async function syncCertificateDomainRecords( async function syncCertificateDomainRecords(
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
domains: string[], domains: string[],
serverIp: string, serverIp: string,
defaultZoneId?: string, defaultZoneId?: string,
): Promise<void> { ): Promise<{ skippedCname: string[] }> {
const skippedCname: string[] = []
for (const domain of domains) { for (const domain of domains) {
const zoneId = await resolveZoneId(token, domain, defaultZoneId) const zoneId = await resolveZoneId(token, domain, defaultZoneId)
await upsertARecord(token, zoneId, domain, serverIp) const result = await upsertARecord(token, zoneId, domain, serverIp)
if (result === "skipped_cname") skippedCname.push(domain)
} }
return { skippedCname }
} }
async function sleep(ms: number) { async function sleep(ms: number) {
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
const finalized = await client.finalizeOrder(order, csr) const finalized = await client.finalizeOrder(order, csr)
const certPem = await client.getCertificate(finalized) const certPem = await client.getCertificate(finalized)
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
const clientRos = MikrotikClient.fromServer(params.server) const clientRos = MikrotikClient.fromServer(params.server)
const serverIp = await resolveServerPublicIp(params.server, clientRos) try {
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId) params.onStep?.("dns_a_sync")
const serverIp = await resolveServerPublicIp(params.server, clientRos)
const { skippedCname } = await syncCertificateDomainRecords(
token,
domains,
serverIp,
settings.defaultZoneId,
)
if (skippedCname.length > 0) {
params.onStep?.(
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
)
}
} catch (e) {
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
params.onStep?.(`dns_a_sync_warn:${msg}`)
}
const trustStores = params.trustStore.filter(Boolean) const trustStores = params.trustStore.filter(Boolean)
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"] const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
+56 -57
View File
@@ -10,6 +10,7 @@ import {
} from "react" } from "react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import type { Domain, IpRange, Asn } from "@/lib/data" import type { Domain, IpRange, Asn } from "@/lib/data"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
export interface EvoBgpCommunityRow { export interface EvoBgpCommunityRow {
id: string id: string
@@ -66,6 +67,12 @@ interface EvoBgpContextValue {
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null) const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
function errorMessage(e: unknown, fallback: string): string {
if (e instanceof ApiClientError) return e.message || fallback
if (e instanceof Error) return e.message || fallback
return fallback
}
export function EvoBGPProvider({ children }: { children: React.ReactNode }) { export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const { mode, backendUrl, backendStatus } = useDataSource() const { mode, backendUrl, backendStatus } = useDataSource()
const [baseUrl, setBaseUrlState] = useState("") const [baseUrl, setBaseUrlState] = useState("")
@@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, { const data = await requestJson<EvoBgpCatalogSnapshot>(
method: "POST", backendUrl,
}) "/api/evobgp/catalog",
const text = await res.text() { method: "POST" },
if (!res.ok) { )
let msg = res.statusText setSnapshot(data)
try {
const j = JSON.parse(text) as { error?: string; detail?: string }
msg = j.error ?? j.detail ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Ошибка EvoBGP")
}
setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot)
} catch (e) { } catch (e) {
setSnapshot(null) setSnapshot(null)
setError(e instanceof Error ? e.message : "Ошибка загрузки") setError(errorMessage(e, "Ошибка загрузки"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
return return
} }
try { try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`) const data = await requestJson<EvoBgpSettingsDto>(
if (!res.ok) throw new Error(await res.text()) backendUrl,
const data = (await res.json()) as EvoBgpSettingsDto "/api/evobgp/settings",
)
setBaseUrlState(data.baseUrl ?? "") setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false) setEnabledState(Boolean(data.enabled))
setSecretConfigured(data.secretConfigured ?? false) setSecretConfigured(Boolean(data.secretConfigured))
setSettingsLoaded(true) setSettingsLoaded(true)
await pullCatalog(data.enabled ?? false) setError(null)
} catch { await pullCatalog(Boolean(data.enabled))
} catch (e) {
setSettingsLoaded(true) setSettingsLoaded(true)
setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP"))
} }
}, [mode, backendStatus, backendUrl, pullCatalog]) }, [mode, backendStatus, backendUrl, pullCatalog])
@@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const saveSettings = useCallback( const saveSettings = useCallback(
async (patch: EvoBgpSavePayload) => { async (patch: EvoBgpSavePayload) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, { const data = await requestJson<EvoBgpSettingsDto>(
method: "PUT", backendUrl,
headers: { "Content-Type": "application/json" }, "/api/evobgp/settings",
body: JSON.stringify(patch), {
}) method: "PUT",
const text = await res.text() body: JSON.stringify(patch),
if (!res.ok) { },
let msg = res.statusText )
try { const nextEnabled = Boolean(data.enabled)
const j = JSON.parse(text) as { error?: string }
msg = j.error ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Не удалось сохранить")
}
const data = JSON.parse(text) as EvoBgpSettingsDto
setBaseUrlState(data.baseUrl ?? "") setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false) setEnabledState(nextEnabled)
setSecretConfigured(data.secretConfigured ?? false) setSecretConfigured(Boolean(data.secretConfigured))
await pullCatalog(data.enabled ?? false) setError(null)
// Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились»)
try {
await pullCatalog(nextEnabled)
} catch {
/* pullCatalog already sets error state */
}
}, },
[backendUrl, pullCatalog], [backendUrl, pullCatalog],
) )
@@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
await pullCatalog(enabled) await pullCatalog(enabled)
}, [enabled, pullCatalog]) }, [enabled, pullCatalog])
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => { const testConnection = useCallback(
try { async (draft?: EvoBgpTestDraft) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, { try {
method: "POST", await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify(draft ?? {}), body: JSON.stringify(draft ?? {}),
}) })
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string } return { ok: true, message: "Соединение с EvoBGP установлено" }
if (!res.ok) throw new Error(data.error ?? res.statusText) } catch (e) {
return { ok: true, message: "Соединение с EvoBGP установлено" } return { ok: false, message: errorMessage(e, "Ошибка") }
} catch (e) { }
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" } },
} [backendUrl],
}, [backendUrl]) )
const value = useMemo( const value = useMemo(
() => ({ () => ({