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

@@ -1 +1 @@
1.0.71 1.0.72

View File

@@ -52,7 +52,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
) )
var version = "1.0.71" var version = "1.0.72"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -204,6 +204,7 @@ func main() {
// /backups — manueller Trigger + Liste + Download. Scheduled- // /backups — manueller Trigger + Liste + Download. Scheduled-
// Jobs laufen im edgeguard-scheduler. // Jobs laufen im edgeguard-scheduler.
handlers.NewBackupHandler(backup.New(pool), auditRepo, nodeID, version).Register(authed) handlers.NewBackupHandler(backup.New(pool), auditRepo, nodeID, version).Register(authed)
handlers.NewDiagnosticsHandler().Register(authed)
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
// Firewall reload: nach jeder Mutation den Renderer neu fahren // Firewall reload: nach jeder Mutation den Renderer neu fahren
// (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen. // (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen.

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
) )
var version = "1.0.71" var version = "1.0.72"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -28,7 +28,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.0.71" var version = "1.0.72"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -0,0 +1,108 @@
package handlers
import (
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/diagnostics"
)
// DiagnosticsHandler exposes /api/v1/diagnostics/{ping,traceroute,
// dig,curl,tcp}. Authentifiziert hinter requireAuth — wir wollen das
// keine externen Scanner als Reflection-Service missbrauchen können.
type DiagnosticsHandler struct{}
func NewDiagnosticsHandler() *DiagnosticsHandler { return &DiagnosticsHandler{} }
func (h *DiagnosticsHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/diagnostics")
g.POST("/ping", h.Ping)
g.POST("/traceroute", h.Traceroute)
g.POST("/dig", h.Dig)
g.POST("/curl", h.Curl)
g.POST("/tcp", h.TCP)
}
type targetReq struct {
Target string `json:"target" binding:"required"`
}
type digReq struct {
Target string `json:"target" binding:"required"`
Type string `json:"type"`
}
type tcpReq struct {
Target string `json:"target" binding:"required"`
Port int `json:"port" binding:"required"`
}
func (h *DiagnosticsHandler) Ping(c *gin.Context) {
var req targetReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
res, err := diagnostics.Ping(c.Request.Context(), req.Target)
if err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, res)
}
func (h *DiagnosticsHandler) Traceroute(c *gin.Context) {
var req targetReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
res, err := diagnostics.Traceroute(c.Request.Context(), req.Target)
if err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, res)
}
func (h *DiagnosticsHandler) Dig(c *gin.Context) {
var req digReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
res, err := diagnostics.Dig(c.Request.Context(), req.Target, req.Type)
if err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, res)
}
func (h *DiagnosticsHandler) Curl(c *gin.Context) {
var req targetReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
res, err := diagnostics.Curl(c.Request.Context(), req.Target)
if err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, res)
}
func (h *DiagnosticsHandler) TCP(c *gin.Context) {
var req tcpReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
res, err := diagnostics.TCPProbe(c.Request.Context(), req.Target, req.Port)
if err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, res)
}

View File

@@ -0,0 +1,174 @@
// Package diagnostics wraps die üblichen Operator-Diagnose-Tools
// (ping/traceroute/dig/curl/tcp-connect) als sichere Shell-Calls. Wird
// vom /api/v1/diagnostics-Handler ausschließlich für authentifizierte
// Admins exponiert.
//
// Sicherheit: alle Targets werden vor dem exec.Command validiert
// (Whitelist [a-zA-Z0-9.:_/-] + max-length), damit kein Shell-Injection
// möglich ist. Wir benutzen exec.CommandContext mit nackten Argument-
// Slices, kein /bin/sh, kein Glob-Expansion.
package diagnostics
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// Result ist die einheitliche Response-Shape für alle Tools.
type Result struct {
Tool string `json:"tool"`
Target string `json:"target"`
Command string `json:"command"`
ExitCode int `json:"exit_code"`
Output string `json:"output"`
DurationMs int64 `json:"duration_ms"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Started time.Time `json:"started"`
Took time.Duration `json:"-"`
}
// validTarget ist eine konservative Erlaubnis: Buchstaben, Ziffern,
// Punkt, Doppelpunkt (IPv6), Schrägstrich (Pfade in curl-URLs), Bindestrich,
// Unterstrich. Whitespace, $, `, `;`, `&`, `|`, `>` etc. werden gesperrt.
func validTarget(s string, max int) error {
s = strings.TrimSpace(s)
if s == "" {
return errors.New("empty target")
}
if len(s) > max {
return fmt.Errorf("target too long (>%d)", max)
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '.', r == ':', r == '/', r == '-', r == '_':
// ok
default:
return fmt.Errorf("invalid char %q in target", r)
}
}
return nil
}
// run führt einen exec.Cmd aus und verpackt ihn in Result. Captures
// stdout+stderr in eine combined-output.
func run(ctx context.Context, tool, target string, timeout time.Duration,
bin string, args ...string) Result {
start := time.Now()
rctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(rctx, bin, args...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
took := time.Since(start)
r := Result{
Tool: tool,
Target: target,
Command: bin + " " + strings.Join(args, " "),
Output: buf.String(),
DurationMs: took.Milliseconds(),
Started: start.UTC(),
Took: took,
ExitCode: 0,
OK: true,
}
if err != nil {
r.OK = false
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
r.ExitCode = exitErr.ExitCode()
} else {
r.ExitCode = -1
r.Error = err.Error()
}
}
if rctx.Err() == context.DeadlineExceeded {
r.Error = "timeout"
}
return r
}
// Ping läuft `ping -c 4 -W 2 <target>`. -c4 = 4 Pakete, -W2 = 2s pro
// Antwort. Total-Timeout 10s.
func Ping(ctx context.Context, target string) (Result, error) {
if err := validTarget(target, 253); err != nil {
return Result{}, err
}
return run(ctx, "ping", target, 12*time.Second,
"/usr/bin/ping", "-c", "4", "-W", "2", target), nil
}
// Traceroute läuft `traceroute -n -w 2 -q 1 -m 20 <target>`. -n = no
// reverse-dns, -q1 = ein Probe pro Hop, -m20 = max 20 Hops.
func Traceroute(ctx context.Context, target string) (Result, error) {
if err := validTarget(target, 253); err != nil {
return Result{}, err
}
return run(ctx, "traceroute", target, 60*time.Second,
"/usr/bin/traceroute", "-n", "-w", "2", "-q", "1", "-m", "20", target), nil
}
// Dig: name + optional type (default A). Konzeptuell `dig +short <type> <name>`
// — wir lassen aber Volltext (TTLs, AUTHORITY etc.) durch damit der Operator
// auch SOA/NS sehen kann.
func Dig(ctx context.Context, name, recType string) (Result, error) {
if err := validTarget(name, 253); err != nil {
return Result{}, err
}
rt := strings.ToUpper(strings.TrimSpace(recType))
if rt == "" {
rt = "A"
}
switch rt {
case "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "PTR", "SRV", "CAA":
// ok
default:
return Result{}, fmt.Errorf("invalid dns type: %s", rt)
}
return run(ctx, "dig", name+"/"+rt, 8*time.Second,
"/usr/bin/dig", "+timeout=3", "+tries=2", rt, name), nil
}
// Curl: HTTP-Probe. Nur Header + Status-Code via `-IsS -m 10` plus
// Resolve-Trace via `-v` damit der Operator alle TLS-/Connect-Details
// sieht.
func Curl(ctx context.Context, urlStr string) (Result, error) {
if err := validTarget(urlStr, 2048); err != nil {
return Result{}, err
}
// Sicherheits-Check: nur http(s) erlauben. Verhindert curl auf
// file:// oder smb:// (curl unterstützt die — wäre lokales Leak).
if !strings.HasPrefix(strings.ToLower(urlStr), "http://") &&
!strings.HasPrefix(strings.ToLower(urlStr), "https://") {
return Result{}, errors.New("only http:// or https:// allowed")
}
return run(ctx, "curl", urlStr, 20*time.Second,
"/usr/bin/curl", "-IsSv", "--max-time", "10",
"--connect-timeout", "5", urlStr), nil
}
// TCPProbe öffnet einen TCP-Connect mit Timeout 5s. Liefert
// "OPEN"/"CLOSED"/"TIMEOUT" plus Latenz.
func TCPProbe(ctx context.Context, host string, port int) (Result, error) {
if err := validTarget(host, 253); err != nil {
return Result{}, err
}
if port < 1 || port > 65535 {
return Result{}, errors.New("port out of range")
}
target := host + ":" + strconv.Itoa(port)
// nc -zv -w 5 host port → -z = scan-only (no I/O), -v = verbose
return run(ctx, "tcp", target, 8*time.Second,
"/usr/bin/nc", "-zv", "-w", "5", host, strconv.Itoa(port)), nil
}

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import {
SafetyCertificateOutlined, SafetyCertificateOutlined,
SettingOutlined, SettingOutlined,
ThunderboltOutlined, ThunderboltOutlined,
ToolOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -74,6 +75,7 @@ const NAV: NavSection[] = [
items: [ items: [
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> }, { path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> }, { path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
{ path: '/diagnostics', labelKey: 'nav.diagnostics', icon: <ToolOutlined /> },
{ path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> }, { path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> },
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> }, { path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> }, { 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: // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent // - <nav> als root, dunkler Gradient + Teal/Blue-Accent

View File

@@ -21,6 +21,7 @@
"cluster": "Cluster", "cluster": "Cluster",
"logs": "Logs", "logs": "Logs",
"backups": "Backups", "backups": "Backups",
"diagnostics": "Diagnose",
"license": "Lizenz", "license": "Lizenz",
"settings": "Einstellungen", "settings": "Einstellungen",
"section": { "section": {
@@ -681,6 +682,20 @@
"src": "Quell-IP" "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": { "backups": {
"title": "Backups", "title": "Backups",
"intro": "Sicherungen der PostgreSQL-Datenbank + /var/lib/edgeguard (Setup, License, JWT, ACME-Account). Täglicher Auto-Job + manueller Trigger.", "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", "cluster": "Cluster",
"logs": "Logs", "logs": "Logs",
"backups": "Backups", "backups": "Backups",
"diagnostics": "Diagnostics",
"license": "License", "license": "License",
"settings": "Settings", "settings": "Settings",
"section": { "section": {
@@ -681,6 +682,20 @@
"src": "Source IP" "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": { "backups": {
"title": "Backups", "title": "Backups",
"intro": "Snapshots of the PostgreSQL database + /var/lib/edgeguard (setup, license, JWT, ACME account). Daily auto job + manual trigger.", "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>
)
}

View File

@@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir
PG Streaming Replication + provider Floating-IP for HTTP ingress). PG Streaming Replication + provider Floating-IP for HTTP ingress).
. .
This package ships the management API, scheduler and CLI. This package ships the management API, scheduler and CLI.
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban
Section: admin Section: admin
Priority: optional Priority: optional