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,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
}