diff --git a/VERSION b/VERSION index 1edd062..eea6f62 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.71 +1.0.72 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 5610170..d7ab364 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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. diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index ce4b3b7..cb9f86e 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.71" +var version = "1.0.72" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 7f281b6..7c397ea 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -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. diff --git a/internal/handlers/diagnostics.go b/internal/handlers/diagnostics.go new file mode 100644 index 0000000..c3c92c8 --- /dev/null +++ b/internal/handlers/diagnostics.go @@ -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) +} diff --git a/internal/services/diagnostics/diagnostics.go b/internal/services/diagnostics/diagnostics.go new file mode 100644 index 0000000..230b6d0 --- /dev/null +++ b/internal/services/diagnostics/diagnostics.go @@ -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 `. -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 +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index a1ea82c..e1aa116 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index 6fc9603..5282fdd 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -21,6 +21,7 @@ const PAGE_TITLES: Record = { '/cluster': 'nav.cluster', '/logs': 'nav.logs', '/backups': 'nav.backups', + '/diagnostics': 'nav.diagnostics', '/license': 'nav.license', '/settings': 'nav.settings', } diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index ad82a25..5b5cb4b 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -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: }, - { path: '/logs', labelKey: 'nav.logs', icon: }, + { path: '/cluster', labelKey: 'nav.cluster', icon: }, + { path: '/logs', labelKey: 'nav.logs', icon: }, + { path: '/diagnostics', labelKey: 'nav.diagnostics', icon: }, { path: '/backups', labelKey: 'nav.backups', icon: }, { path: '/license', labelKey: 'nav.license', icon: }, { path: '/settings', labelKey: 'nav.settings', icon: }, @@ -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: // -