// 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 `. -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 `. -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 ` // — 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 }