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:
@@ -52,7 +52,7 @@ import (
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
)
|
||||
|
||||
var version = "1.0.71"
|
||||
var version = "1.0.72"
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||
@@ -204,6 +204,7 @@ func main() {
|
||||
// /backups — manueller Trigger + Liste + Download. Scheduled-
|
||||
// Jobs laufen im edgeguard-scheduler.
|
||||
handlers.NewBackupHandler(backup.New(pool), auditRepo, nodeID, version).Register(authed)
|
||||
handlers.NewDiagnosticsHandler().Register(authed)
|
||||
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
|
||||
// Firewall reload: nach jeder Mutation den Renderer neu fahren
|
||||
// (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.71"
|
||||
var version = "1.0.72"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
var version = "1.0.71"
|
||||
var version = "1.0.72"
|
||||
|
||||
const (
|
||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||
|
||||
108
internal/handlers/diagnostics.go
Normal file
108
internal/handlers/diagnostics.go
Normal 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)
|
||||
}
|
||||
174
internal/services/diagnostics/diagnostics.go
Normal file
174
internal/services/diagnostics/diagnostics.go
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir
|
||||
PG Streaming Replication + provider Floating-IP for HTTP ingress).
|
||||
.
|
||||
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
|
||||
Section: admin
|
||||
Priority: optional
|
||||
|
||||
Reference in New Issue
Block a user