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:
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
244
management-ui/src/pages/Diagnostics/index.tsx
Normal file
244
management-ui/src/pages/Diagnostics/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user