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>
175 lines
5.4 KiB
Go
175 lines
5.4 KiB
Go
// 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
|
|
}
|