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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user