fix(network): защитить ключи WireGuard и режим просмотра операций

This commit is contained in:
Denozordec committed 2026-10-13 20:12:33 +07:00
1 parent c8f9c83f45
commit a404da768d
23 files changed
+1648 -36

No files matched your search

+3 -1
View File
@@ -1497,7 +1497,7 @@ function FiltersPage() {
const allTunnels = !isLive ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const mutationsLocked = movePending || (isLive && (applyBusy || historyRestoring || rulesReading || readyServerId !== selectedServerId || liveStale || liveLoadState !== "idle"))
const mutationsLocked = (isLive && !can("mm:filters:write")) || movePending || (isLive && (applyBusy || historyRestoring || rulesReading || readyServerId !== selectedServerId || liveStale || liveLoadState !== "idle"))
const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
@@ -1548,6 +1548,7 @@ function FiltersPage() {
nextRules: FilterRule[] | (() => Promise<FilterRule[]>),
source: "apply" | "copy" = "apply",
) => {
if (isLive && !can("mm:filters:write")) return { ok: false, error: "Нет прав на изменение фильтров" }
if (!mounted.current) return { ok: false, error: "Источник данных изменён" }
if (applyLock.current || applyBusy || historyRestoring) return { ok: false, error: "Дождитесь завершения текущей операции" }
if (isLive && (liveStale || liveLoadState !== "idle" || (serverId === selectedServerId && readyServerId !== serverId))) {
@@ -1864,6 +1865,7 @@ function FiltersPage() {
}
banner={
<>
{isLive && !can("mm:filters:write") && <p role="status" className="border-b px-6 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение фильтров недоступно.</p>}
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
+2 -1
View File
@@ -2169,7 +2169,7 @@ function FirewallPageInner() {
const scopedFailures = isLive ? failures.filter(f => (selectedServerId === ALL_SERVERS_ID || f.serverId === selectedServerId) && (!f.family || f.family === ipFamily)) : []
const firewallIncomplete = scopedFailures.length > 0
const firewallWriteBlocked = firewallIncomplete || (isLive && !readTrusted)
const firewallWriteBlocked = (isLive && !can("mm:network:write")) || firewallIncomplete || (isLive && !readTrusted)
const scopedSliceTimes = sliceTimes.filter(slice => (selectedServerId === ALL_SERVERS_ID || slice.serverId === selectedServerId) && slice.family === ipFamily)
const freshness = firewallTimeRange(scopedSliceTimes)
const totalEnabled = familyRules.filter((r) => r.enabled).length
@@ -2530,6 +2530,7 @@ function FirewallPageInner() {
showAll
allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0}
banner={isLive && !can("mm:network:write") ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации недоступно.</p> : undefined}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Firewall" }]}
+2 -1
View File
@@ -257,7 +257,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const mutationsLocked = !isLive || mutateBusy || dataLoading || !hasLoaded || liveStale || historyRestoring
const mutationsLocked = !isLive || !can("mm:network:write") || mutateBusy || dataLoading || !hasLoaded || liveStale || historyRestoring
function canManage(serverId: string) {
return !mutationsLocked && !failedServerIds.includes(serverId) && liveServers.some(s => s.id === serverId && s.enabled)
}
@@ -548,6 +548,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
showAll
allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0}
banner={isLive && !can("mm:network:write") ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации недоступно.</p> : undefined}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
+2 -1
View File
@@ -320,7 +320,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
const failedServerIds = new Set(failures.map(f => f.serverId))
const scopeUnknown = missingSelectedServer || isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError &&
const canManage = (serverId: string) => isLive && can("mm:network:write") && hasLoaded && !dataError && !inventoryError &&
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
const assertDeviceReady = (serverId: string) => {
if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
@@ -637,6 +637,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor
showAll
allCount={displayClients.length}
loading={isLive && loading && displayServers.length === 0}
banner={isLive && !can("mm:network:write") ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации недоступно.</p> : undefined}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
+21 -12
View File
@@ -184,6 +184,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
}) {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const canWrite = !isLive || can("mm:network:write")
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
@@ -446,6 +447,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setCreateError(null)
setBusy(true)
try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const ep = parseEndpoint(form.peerEndpoint)
await createWireGuardInterface(backendUrl, {
serverId: form.serverId,
@@ -495,6 +497,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setImportError(null)
setBusy(true)
try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const res = await importWireGuard(backendUrl, {
serverId: args.serverId,
content: args.content,
@@ -538,6 +541,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setTogglePending(context)
setToggleError(null)
try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const result = await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
disabled: iface.enabled,
})
@@ -564,6 +568,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setDeleteBusy(true)
setDeleteError(null)
try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
if (pendingDelete.kind === "iface") {
if (!pendingDelete.iface.rosId) {
if (active.current) toast.info("Доступно только в live-режиме")
@@ -599,6 +604,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setPeerError(null)
setBusy(true)
try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const ep = parseEndpoint(form.endpoint)
await createWireGuardPeer(backendUrl, {
serverId: peerIface.serverId,
@@ -630,6 +636,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
exportPending.current = epoch
setExportBusy(true)
try {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
const peer = format === "peer-conf" ? selectWgExportPeer(exportIface.peers, exportPeerId) : undefined
if (format === "peer-conf" && !peer) throw new Error("Выбранный пир не найден. Обновите список и выберите пира заново.")
const res = await exportWireGuard(backendUrl, {
@@ -671,7 +678,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
}
const emptyCreateAction = (
<Button size="sm" disabled={!!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
@@ -687,6 +694,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
showAll
allCount={completeScope ? displayIfaces.length : undefined}
loading={isLive && loading && displayServers.length === 0}
banner={!canWrite ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации и экспорт ключей недоступны.</p> : undefined}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
@@ -719,11 +727,11 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
</Button>
</>
)}
<Button size="sm" variant="outline" disabled={!!urlError || !catalogKnown || loadError} onClick={() => { setImportError(null); setImportOpen(true) }}>
<Button size="sm" variant="outline" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setImportError(null); setImportOpen(true) }}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
@@ -843,15 +851,16 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
/>
{catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? <WireguardDataGrid
interfaces={filteredIfaces}
canWrite={canWrite}
compactServer={compactServer}
emptyAction={emptyCreateAction}
onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
onToggle={selectedSourceError ? undefined : handleToggle}
onAddPeer={!canWrite || selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
onToggle={!canWrite || selectedSourceError ? undefined : handleToggle}
toggleBusy={togglePending !== null}
onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
onDelete={!canWrite || selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
onDeletePeer={!canWrite || selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
onExportPeer={canWrite ? (iface, peerId) => openExport(iface, "peer", peerId) : undefined}
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог интерфейсов пока неизвестен</p>}
</DataPageCard>
</TabsContent>
@@ -869,22 +878,22 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
compactServer={compactServer}
emptyAction={
scopedIfaces.length === 1 ? (
<Button size="sm" disabled={selectedSourceError} onClick={() => { setPeerError(null); setPeerIface(scopedIfaces[0]) }}>
<Button size="sm" disabled={!canWrite || selectedSourceError} onClick={() => { setPeerError(null); setPeerIface(scopedIfaces[0]) }}>
<PlusIcon className="size-4" />
Добавить пира
</Button>
) : emptyCreateAction
}
onDeletePeer={selectedSourceError ? undefined : (row) => {
onDeletePeer={!canWrite || selectedSourceError ? undefined : (row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
if (!iface) return
setPendingDelete({ kind: "peer", iface, peerId: row.id })
}}
onExportPeer={(row) => {
onExportPeer={canWrite ? (row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
if (!iface) return
openExport(iface, "peer", row.id)
}}
} : undefined}
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог пиров пока неизвестен</p>}
</DataPageCard>
</TabsContent>