feat(diagnostics): UI-Tools — ping/traceroute/dig/curl/tcp

Operator-Diagnose ohne SSH. Sidebar → System → Diagnose.

internal/services/diagnostics/:
  - Ping        — ping -c4 -W2 (12s timeout)
  - Traceroute  — traceroute -n -w2 -q1 -m20 (60s timeout)
  - Dig         — dig +timeout=3 +tries=2 <TYPE> <NAME> (Types A/AAAA/
                  CNAME/MX/TXT/NS/SOA/PTR/SRV/CAA whitelisted)
  - Curl        — curl -IsSv --max-time 10 (nur http(s)://, kein
                  file:// / smb:// / data://)
  - TCPProbe    — nc -zv -w5 (8s timeout)

Sicherheit: validTarget() prüft jeden Input gegen Allow-List
[a-zA-Z0-9.:/_-]; verhindert Shell-Metachar-Injection. exec.Command
mit nackten Argument-Slices (kein /bin/sh, kein Glob-Expansion).

internal/handlers/diagnostics.go: POST /api/v1/diagnostics/<tool>
hinter requireAuth.

UI (pages/Diagnostics): 5 Tool-Cards, jede mit eigenem Input + Run-
Button + monospace-Output-Pane (dunkel, scrollbar, max-height 320px).
Pro Tool ein Status-Tag (OK / exit N) + Dauer-ms. info-Alert oben
erklärt dass Tools auf der Box laufen, nicht im Browser; security-
Alert unten erklärt die Restrictions.

control: iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
als Depends. Auf Test-Box bereits da (waren Distro-defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 15:30:07 +02:00
parent e07b484a48
commit 5bdea1bced
13 changed files with 570 additions and 8 deletions

View File

@@ -28,6 +28,7 @@ const ClusterPage = lazy(() => import('./pages/Cluster'))
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
const LogsPage = lazy(() => import('./pages/Logs'))
const BackupsPage = lazy(() => import('./pages/Backups'))
const DiagnosticsPage = lazy(() => import('./pages/Diagnostics'))
const LicensePage = lazy(() => import('./pages/License'))
const SettingsPage = lazy(() => import('./pages/Settings'))
@@ -115,6 +116,7 @@ export default function App() {
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/diagnostics" element={<DiagnosticsPage />} />
<Route path="/license" element={<LicensePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -21,6 +21,7 @@ const PAGE_TITLES: Record<string, string> = {
'/cluster': 'nav.cluster',
'/logs': 'nav.logs',
'/backups': 'nav.backups',
'/diagnostics': 'nav.diagnostics',
'/license': 'nav.license',
'/settings': 'nav.settings',
}

View File

@@ -16,6 +16,7 @@ import {
SafetyCertificateOutlined,
SettingOutlined,
ThunderboltOutlined,
ToolOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
@@ -72,8 +73,9 @@ const NAV: NavSection[] = [
{
labelKey: 'nav.section.system',
items: [
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
{ path: '/diagnostics', labelKey: 'nav.diagnostics', icon: <ToolOutlined /> },
{ path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> },
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
@@ -81,7 +83,7 @@ const NAV: NavSection[] = [
},
]
const VERSION = '1.0.71'
const VERSION = '1.0.72'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent

View File

@@ -21,6 +21,7 @@
"cluster": "Cluster",
"logs": "Logs",
"backups": "Backups",
"diagnostics": "Diagnose",
"license": "Lizenz",
"settings": "Einstellungen",
"section": {
@@ -681,6 +682,20 @@
"src": "Quell-IP"
}
},
"diag": {
"title": "Diagnose",
"intro": "Operator-Tools direkt aus dem UI: ping, traceroute, DNS, HTTP-Probe, TCP-Connect. Alle Calls laufen authentifiziert auf dieser Box (nicht im Browser).",
"run": "Ausführen",
"runFromBoxTitle": "Tools laufen auf der EdgeGuard-Box",
"runFromBoxDesc": "Diese Aufrufe nutzen die Outbound-Connectivity der EdgeGuard — nicht deinen lokalen Rechner. Wenn ping/curl auf der Box scheitert, ist das Box-Network-Layer das Problem (Firewall-Rules, Default-Route, DNS).",
"ping": { "intro": "ICMP echo-request × 4, 2s Timeout pro Paket. Misst Loss + RTT." },
"trace": { "intro": "Hop-by-Hop-Pfad mit UDP-Probes (max 20 Hops, kein reverse-DNS)." },
"dig": { "intro": "DNS-Lookup über unbound (Box-Resolver). Zeigt Antwort + AUTHORITY/ADDITIONAL Section." },
"curl": { "intro": "HTTPS-Probe mit -IsSv: TLS-Handshake-Details + Header. Folgt KEINE Redirects." },
"tcp": { "intro": "Pure TCP-Connect ohne I/O. Ideal um zu prüfen ob ein Backend-Port erreichbar ist." },
"securityTitle": "Sicherheits-Hinweis",
"securityDesc": "Endpoints sind hinter Admin-Auth. Targets werden gegen eine strikte Allow-Liste validiert (keine Shell-Metazeichen). curl ist auf http(s) beschränkt — kein file:// / smb:// / data://."
},
"backups": {
"title": "Backups",
"intro": "Sicherungen der PostgreSQL-Datenbank + /var/lib/edgeguard (Setup, License, JWT, ACME-Account). Täglicher Auto-Job + manueller Trigger.",

View File

@@ -21,6 +21,7 @@
"cluster": "Cluster",
"logs": "Logs",
"backups": "Backups",
"diagnostics": "Diagnostics",
"license": "License",
"settings": "Settings",
"section": {
@@ -681,6 +682,20 @@
"src": "Source IP"
}
},
"diag": {
"title": "Diagnostics",
"intro": "Operator tools straight from the UI: ping, traceroute, DNS, HTTP probe, TCP connect. All calls run authenticated on this box (not in the browser).",
"run": "Run",
"runFromBoxTitle": "Tools run on the EdgeGuard box",
"runFromBoxDesc": "These calls use the EdgeGuard's outbound connectivity — not your laptop's. If ping/curl fails here, the box's network layer is the issue (firewall rules, default route, DNS).",
"ping": { "intro": "ICMP echo-request × 4, 2 s timeout per packet. Measures loss + RTT." },
"trace": { "intro": "Hop-by-hop path with UDP probes (max 20 hops, no reverse-DNS)." },
"dig": { "intro": "DNS lookup via unbound (box resolver). Shows answer + AUTHORITY/ADDITIONAL section." },
"curl": { "intro": "HTTPS probe with -IsSv: TLS handshake details + headers. Does NOT follow redirects." },
"tcp": { "intro": "Pure TCP connect (no I/O). Ideal to verify a backend port is reachable." },
"securityTitle": "Security note",
"securityDesc": "Endpoints are behind admin auth. Targets are validated against a strict allow-list (no shell metachars). curl is restricted to http(s) — no file:// / smb:// / data://."
},
"backups": {
"title": "Backups",
"intro": "Snapshots of the PostgreSQL database + /var/lib/edgeguard (setup, license, JWT, ACME account). Daily auto job + manual trigger.",

View File

@@ -0,0 +1,244 @@
import { useState } from 'react'
import {
Alert, Button, Card, Col, Input, InputNumber, Row, Select, Space, Tag, Tooltip, Typography, message,
} from 'antd'
import {
AimOutlined, ApiOutlined, GlobalOutlined, NodeIndexOutlined, RadarChartOutlined,
SearchOutlined, SwapOutlined, ToolOutlined,
} from '@ant-design/icons'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
interface DiagResult {
tool: string
target: string
command: string
exit_code: number
output: string
duration_ms: number
ok: boolean
error?: string
started: string
}
// ToolCard ist die generische Container-Komponente. Jedes Tool hat:
// - Eigene Input-Form
// - "Ausführen"-Button → POST /diagnostics/<tool>
// - Output-Pane mit monospace-Output, Status-Tag (OK/Error), Dauer
function ToolCard({ icon, title, intro, children, result, loading, run }: {
icon: React.ReactNode
title: string
intro: string
children: React.ReactNode
result: DiagResult | null
loading: boolean
run: () => void
}) {
const { t } = useTranslation()
return (
<Card
size="small"
title={<Space>{icon}<Text strong>{title}</Text></Space>}
extra={
<Button type="primary" size="small" onClick={run} loading={loading}
icon={<RadarChartOutlined />}>
{t('diag.run')}
</Button>
}
>
<Text type="secondary" style={{ fontSize: 12 }}>{intro}</Text>
<div style={{ marginTop: 8 }}>{children}</div>
{result && (
<div style={{ marginTop: 12 }}>
<Space size={6} wrap>
<Tag color={result.ok ? 'green' : 'red'}>
{result.ok ? 'OK' : `exit ${result.exit_code}`}
</Tag>
<Text type="secondary" style={{ fontSize: 11 }}>
{result.duration_ms} ms
</Text>
<Tooltip title={result.command}>
<Text type="secondary" style={{ fontSize: 11, fontFamily: 'monospace' }}>
{result.command.length > 60 ? result.command.slice(0, 60) + '…' : result.command}
</Text>
</Tooltip>
</Space>
<pre style={{
marginTop: 8, padding: 10, background: '#0F172A',
color: '#CBD5E1', fontFamily: 'monospace', fontSize: 12,
borderRadius: 6, overflow: 'auto', maxHeight: 320,
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{result.output || result.error || '(kein Output)'}
</pre>
</div>
)}
</Card>
)
}
export default function DiagnosticsPage() {
const { t } = useTranslation()
const [pingTarget, setPingTarget] = useState('1.1.1.1')
const [traceTarget, setTraceTarget] = useState('1.1.1.1')
const [digName, setDigName] = useState('netcell-it.de')
const [digType, setDigType] = useState('A')
const [curlUrl, setCurlUrl] = useState('https://1.1.1.1')
const [tcpHost, setTcpHost] = useState('1.1.1.1')
const [tcpPort, setTcpPort] = useState(443)
const [pingRes, setPingRes] = useState<DiagResult | null>(null)
const [traceRes, setTraceRes] = useState<DiagResult | null>(null)
const [digRes, setDigRes] = useState<DiagResult | null>(null)
const [curlRes, setCurlRes] = useState<DiagResult | null>(null)
const [tcpRes, setTcpRes] = useState<DiagResult | null>(null)
function makeRunner<T>(path: string, body: T,
set: (r: DiagResult | null) => void) {
return useMutation({
mutationFn: async () => {
const r = await apiClient.post(`/diagnostics/${path}`, body)
return isEnvelope(r.data) ? (r.data.data as DiagResult) : null
},
onSuccess: (data) => { set(data) },
onError: (e: Error) => {
set(null)
message.error(e.message)
},
})
}
const ping = makeRunner('ping', { target: pingTarget }, setPingRes)
const trace = makeRunner('traceroute', { target: traceTarget }, setTraceRes)
const dig = makeRunner('dig', { target: digName, type: digType }, setDigRes)
const curl = makeRunner('curl', { target: curlUrl }, setCurlRes)
const tcp = makeRunner('tcp', { target: tcpHost, port: tcpPort }, setTcpRes)
return (
<div>
<PageHeader
icon={<ToolOutlined />}
title={t('diag.title')}
subtitle={t('diag.intro')}
/>
<Alert
type="info"
showIcon
className="mb-16"
message={t('diag.runFromBoxTitle')}
description={t('diag.runFromBoxDesc')}
/>
<Row gutter={[12, 12]}>
<Col xs={24} lg={12}>
<ToolCard
icon={<AimOutlined />}
title="ping"
intro={t('diag.ping.intro')}
result={pingRes}
loading={ping.isPending}
run={() => ping.mutate()}
>
<Input placeholder="1.1.1.1 oder example.com"
value={pingTarget}
onChange={(e) => setPingTarget(e.target.value)}
onPressEnter={() => ping.mutate()} />
</ToolCard>
</Col>
<Col xs={24} lg={12}>
<ToolCard
icon={<NodeIndexOutlined />}
title="traceroute"
intro={t('diag.trace.intro')}
result={traceRes}
loading={trace.isPending}
run={() => trace.mutate()}
>
<Input placeholder="1.1.1.1 oder example.com"
value={traceTarget}
onChange={(e) => setTraceTarget(e.target.value)}
onPressEnter={() => trace.mutate()} />
</ToolCard>
</Col>
<Col xs={24} lg={12}>
<ToolCard
icon={<SearchOutlined />}
title="dig (DNS)"
intro={t('diag.dig.intro')}
result={digRes}
loading={dig.isPending}
run={() => dig.mutate()}
>
<Space.Compact style={{ width: '100%' }}>
<Input placeholder="netcell-it.de"
value={digName}
onChange={(e) => setDigName(e.target.value)}
onPressEnter={() => dig.mutate()}
style={{ flex: 1 }} />
<Select
value={digType}
onChange={setDigType}
options={['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'PTR', 'SRV', 'CAA']
.map((v) => ({ value: v, label: v }))}
style={{ width: 100 }} />
</Space.Compact>
</ToolCard>
</Col>
<Col xs={24} lg={12}>
<ToolCard
icon={<GlobalOutlined />}
title="HTTP-Probe (curl -IsSv)"
intro={t('diag.curl.intro')}
result={curlRes}
loading={curl.isPending}
run={() => curl.mutate()}
>
<Input placeholder="https://example.com"
value={curlUrl}
onChange={(e) => setCurlUrl(e.target.value)}
onPressEnter={() => curl.mutate()} />
</ToolCard>
</Col>
<Col xs={24}>
<ToolCard
icon={<SwapOutlined />}
title="TCP Connect"
intro={t('diag.tcp.intro')}
result={tcpRes}
loading={tcp.isPending}
run={() => tcp.mutate()}
>
<Space.Compact style={{ width: '100%' }}>
<Input placeholder="10.0.5.14"
value={tcpHost}
onChange={(e) => setTcpHost(e.target.value)}
onPressEnter={() => tcp.mutate()}
style={{ flex: 1 }} />
<InputNumber
min={1} max={65535}
value={tcpPort}
onChange={(v) => setTcpPort(typeof v === 'number' ? v : 0)}
style={{ width: 120 }} />
</Space.Compact>
</ToolCard>
</Col>
</Row>
<Alert
type="warning"
showIcon
className="mt-16"
icon={<ApiOutlined />}
message={t('diag.securityTitle')}
description={t('diag.securityDesc')}
/>
</div>
)
}