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

@@ -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>
)
}