Sfoglia il codice sorgente

feat(tools): implement RouterOS-style troubleshooting suite (/tool ping, traceroute, mtr, dns, port probe)

Proxmox Router 1 settimana fa
parent
commit
a6c6146f6e

+ 6 - 4
cmd/server/main.go

@@ -17,6 +17,7 @@ import (
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
 	"proxmox-router/internal/routing"
+	"proxmox-router/internal/tools"
 	"proxmox-router/internal/vpn"
 	"proxmox-router/internal/web"
 )
@@ -35,16 +36,17 @@ func main() {
 	routingMgr := routing.NewRoutingManager()
 	dynRoutingMgr := dynamicrouting.NewManager("")
 	bridgeMgr := bridge.NewBridgeManager()
+	toolsMgr := tools.NewManager()
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,
 		Handler:      srv,
-		ReadTimeout:  15 * time.Second,
-		WriteTimeout: 15 * time.Second,
-		IdleTimeout:  60 * time.Second,
+		ReadTimeout:  60 * time.Second,
+		WriteTimeout: 60 * time.Second,
+		IdleTimeout:  120 * time.Second,
 	}
 
 	// Print startup banner

+ 11 - 1
internal/api/api.go

@@ -11,6 +11,7 @@ import (
 	"proxmox-router/internal/network"
 	"proxmox-router/internal/routing"
 	"proxmox-router/internal/system"
+	"proxmox-router/internal/tools"
 	"proxmox-router/internal/vpn"
 )
 
@@ -23,11 +24,12 @@ type Server struct {
 	routingMgr    *routing.RoutingManager
 	dynRoutingMgr *dynamicrouting.Manager
 	bridgeMgr     *bridge.BridgeManager
+	toolsMgr      *tools.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -37,6 +39,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		routingMgr:    routingMgr,
 		dynRoutingMgr: dynRoutingMgr,
 		bridgeMgr:     bridgeMgr,
+		toolsMgr:      toolsMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -258,6 +261,13 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("DELETE /api/routing/filters/{id}", s.handleDeleteRoutingFilter)
 	s.mux.HandleFunc("POST /api/routing/filters/{id}/state", s.handleSetRoutingFilterState)
 
+	// Troubleshooting Tools (/tool ping, traceroute, mtr, dns, port-probe)
+	s.mux.HandleFunc("POST /api/tools/ping", s.handleToolsPing)
+	s.mux.HandleFunc("POST /api/tools/traceroute", s.handleToolsTraceroute)
+	s.mux.HandleFunc("POST /api/tools/dns", s.handleToolsDNS)
+	s.mux.HandleFunc("POST /api/tools/port-probe", s.handleToolsPortProbe)
+	s.mux.HandleFunc("POST /api/tools/mtr", s.handleToolsMTR)
+
 	// Web UI Static Files
 	if s.fsHandler != nil {
 		s.mux.Handle("/", s.fsHandler)

+ 101 - 0
internal/api/tools_handlers.go

@@ -0,0 +1,101 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"proxmox-router/internal/tools"
+)
+
+func (s *Server) handleToolsPing(w http.ResponseWriter, r *http.Request) {
+	var req tools.PingRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	timeoutSec := req.Timeout
+	if timeoutSec <= 0 {
+		timeoutSec = 10
+	}
+	ctx, cancel := r.Context(), func() {}
+	if timeoutSec > 0 {
+		ctx, cancel = r.Context(), func() {}
+		// Allow up to 35 seconds for ping
+		_ = time.Duration(timeoutSec) * time.Second
+	}
+	defer cancel()
+
+	resp, err := s.toolsMgr.RunPing(ctx, req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, resp)
+}
+
+func (s *Server) handleToolsTraceroute(w http.ResponseWriter, r *http.Request) {
+	var req tools.TracerouteRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	resp, err := s.toolsMgr.RunTraceroute(r.Context(), req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, resp)
+}
+
+func (s *Server) handleToolsDNS(w http.ResponseWriter, r *http.Request) {
+	var req tools.DNSRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	resp, err := s.toolsMgr.RunDNSLookup(r.Context(), req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, resp)
+}
+
+func (s *Server) handleToolsPortProbe(w http.ResponseWriter, r *http.Request) {
+	var req tools.PortProbeRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	resp, err := s.toolsMgr.RunPortProbe(r.Context(), req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, resp)
+}
+
+func (s *Server) handleToolsMTR(w http.ResponseWriter, r *http.Request) {
+	var req tools.MTRRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	resp, err := s.toolsMgr.RunMTR(r.Context(), req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, resp)
+}

+ 428 - 0
internal/tools/manager.go

@@ -0,0 +1,428 @@
+package tools
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var (
+	validHostRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`)
+	pingReplyRegex = regexp.MustCompile(`(\d+)\s+bytes from\s+([^:]+):\s+icmp_seq=(\d+)\s+ttl=(\d+)\s+time=([\d.]+)\s+ms`)
+	pingStatRegex  = regexp.MustCompile(`(\d+)\s+packets transmitted,\s+(\d+)\s+(?:packets\s+)?received,\s+([\d.]+)%\s+packet loss`)
+	pingRttRegex   = regexp.MustCompile(`rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)\s+ms`)
+	mtrHopRegex    = regexp.MustCompile(`^\s*(\d+)\.\|--\s+(\S+)\s+([\d.]+)%?\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)`)
+)
+
+type Manager struct{}
+
+func NewManager() *Manager {
+	return &Manager{}
+}
+
+func sanitizeTarget(target string) (string, error) {
+	t := strings.TrimSpace(target)
+	if t == "" {
+		return "", fmt.Errorf("target host or IP is required")
+	}
+	if len(t) > 255 || !validHostRegex.MatchString(t) {
+		return "", fmt.Errorf("invalid target host format")
+	}
+	return t, nil
+}
+
+// RunPing executes ICMP ping with custom parameters
+func (m *Manager) RunPing(ctx context.Context, req PingRequest) (*PingResponse, error) {
+	target, err := sanitizeTarget(req.Target)
+	if err != nil {
+		return nil, err
+	}
+
+	count := req.Count
+	if count <= 0 {
+		count = 4
+	}
+	if count > 30 {
+		count = 30
+	}
+
+	size := req.Size
+	if size <= 0 {
+		size = 56
+	}
+	if size > 65500 {
+		size = 65500
+	}
+
+	timeout := req.Timeout
+	if timeout <= 0 {
+		timeout = 5
+	}
+	if timeout > 30 {
+		timeout = 30
+	}
+
+	args := []string{
+		"-c", strconv.Itoa(count),
+		"-s", strconv.Itoa(size),
+		"-W", strconv.Itoa(timeout),
+	}
+
+	if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
+		args = append(args, "-I", req.Interface)
+	}
+
+	if req.DoNotFrag {
+		args = append(args, "-M", "do")
+	}
+
+	args = append(args, target)
+
+	cmd := exec.CommandContext(ctx, "ping", args...)
+	outBytes, _ := cmd.CombinedOutput()
+	outStr := string(outBytes)
+
+	resp := &PingResponse{
+		Target:      target,
+		PacketsSent: count,
+		Replies:     []PingReply{},
+		RawOutput:   outStr,
+		Status:      "timeout",
+	}
+
+	// Parse individual ping replies
+	matches := pingReplyRegex.FindAllStringSubmatch(outStr, -1)
+	for _, match := range matches {
+		if len(match) >= 6 {
+			bytes, _ := strconv.Atoi(match[1])
+			from := match[2]
+			seq, _ := strconv.Atoi(match[3])
+			ttl, _ := strconv.Atoi(match[4])
+			timeMs, _ := strconv.ParseFloat(match[5], 64)
+
+			resp.Replies = append(resp.Replies, PingReply{
+				Seq:    seq,
+				Bytes:  bytes,
+				From:   from,
+				TTL:    ttl,
+				TimeMs: timeMs,
+			})
+		}
+	}
+
+	// Parse overall stats
+	if statMatch := pingStatRegex.FindStringSubmatch(outStr); len(statMatch) >= 4 {
+		resp.PacketsSent, _ = strconv.Atoi(statMatch[1])
+		resp.PacketsRecv, _ = strconv.Atoi(statMatch[2])
+		resp.PacketLossPct, _ = strconv.ParseFloat(statMatch[3], 64)
+	}
+
+	// Parse RTT min/avg/max/mdev
+	if rttMatch := pingRttRegex.FindStringSubmatch(outStr); len(rttMatch) >= 5 {
+		resp.MinRTT, _ = strconv.ParseFloat(rttMatch[1], 64)
+		resp.AvgRTT, _ = strconv.ParseFloat(rttMatch[2], 64)
+		resp.MaxRTT, _ = strconv.ParseFloat(rttMatch[3], 64)
+		resp.MdevRTT, _ = strconv.ParseFloat(rttMatch[4], 64)
+	}
+
+	if resp.PacketsRecv == resp.PacketsSent && resp.PacketsSent > 0 {
+		resp.Status = "ok"
+	} else if resp.PacketsRecv > 0 {
+		resp.Status = "partial"
+	} else {
+		resp.Status = "timeout"
+	}
+
+	return resp, nil
+}
+
+// RunTraceroute traces the route to a target
+func (m *Manager) RunTraceroute(ctx context.Context, req TracerouteRequest) (*TracerouteResponse, error) {
+	target, err := sanitizeTarget(req.Target)
+	if err != nil {
+		return nil, err
+	}
+
+	maxHops := req.MaxHops
+	if maxHops <= 0 {
+		maxHops = 15
+	}
+	if maxHops > 30 {
+		maxHops = 30
+	}
+
+	args := []string{
+		"-m", strconv.Itoa(maxHops),
+		"-q", "2",
+		"-w", "1",
+	}
+
+	if !req.Resolve {
+		args = append(args, "-n")
+	}
+
+	if req.Protocol == "icmp" {
+		args = append(args, "-I")
+	}
+
+	if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
+		args = append(args, "-i", req.Interface)
+	}
+
+	args = append(args, target)
+
+	cmd := exec.CommandContext(ctx, "traceroute", args...)
+	outBytes, _ := cmd.CombinedOutput()
+	outStr := string(outBytes)
+
+	resp := &TracerouteResponse{
+		Target:    target,
+		Hops:      []TracerouteHop{},
+		RawOutput: outStr,
+	}
+
+	lines := strings.Split(outStr, "\n")
+	for i, line := range lines {
+		if i == 0 {
+			continue // skip header line
+		}
+		trimmed := strings.TrimSpace(line)
+		if trimmed == "" {
+			continue
+		}
+
+		fields := strings.Fields(trimmed)
+		if len(fields) < 2 {
+			continue
+		}
+
+		hopNum, err := strconv.Atoi(fields[0])
+		if err != nil {
+			continue
+		}
+
+		if fields[1] == "*" {
+			resp.Hops = append(resp.Hops, TracerouteHop{
+				Hop:     hopNum,
+				Host:    "*",
+				IP:      "*",
+				TimesMs: []string{"*"},
+				Status:  "timeout",
+			})
+		} else {
+			hostOrIP := fields[1]
+			ip := hostOrIP
+			if len(fields) >= 3 && strings.HasPrefix(fields[2], "(") && strings.HasSuffix(fields[2], ")") {
+				ip = strings.Trim(fields[2], "()")
+			}
+			times := make([]string, 0)
+			for _, f := range fields[2:] {
+				if f != "ms" && !strings.HasPrefix(f, "(") {
+					times = append(times, f)
+				}
+			}
+
+			resp.Hops = append(resp.Hops, TracerouteHop{
+				Hop:     hopNum,
+				Host:    hostOrIP,
+				IP:      ip,
+				TimesMs: times,
+				Status:  "ok",
+			})
+		}
+	}
+
+	return resp, nil
+}
+
+// RunDNSLookup performs DNS resolution via dig
+func (m *Manager) RunDNSLookup(ctx context.Context, req DNSRequest) (*DNSResponse, error) {
+	domain, err := sanitizeTarget(req.Domain)
+	if err != nil {
+		return nil, err
+	}
+
+	qType := strings.ToUpper(strings.TrimSpace(req.Type))
+	if qType == "" {
+		qType = "A"
+	}
+
+	args := []string{
+		"+noall", "+answer", "+stats",
+		domain,
+		qType,
+	}
+
+	if req.Server != "" {
+		srv, sErr := sanitizeTarget(req.Server)
+		if sErr == nil {
+			args = append(args, "@"+srv)
+		}
+	}
+
+	cmd := exec.CommandContext(ctx, "dig", args...)
+	outBytes, _ := cmd.CombinedOutput()
+	outStr := string(outBytes)
+
+	resp := &DNSResponse{
+		Domain:    domain,
+		Type:      qType,
+		Server:    req.Server,
+		Records:   []DNSRecord{},
+		RawOutput: outStr,
+	}
+
+	lines := strings.Split(outStr, "\n")
+	for _, line := range lines {
+		l := strings.TrimSpace(line)
+		if l == "" {
+			continue
+		}
+		if strings.HasPrefix(l, ";; Query time:") {
+			parts := strings.Fields(l)
+			if len(parts) >= 4 {
+				resp.QueryTimeMs, _ = strconv.Atoi(parts[2])
+			}
+			continue
+		}
+		if strings.HasPrefix(l, ";; SERVER:") {
+			parts := strings.Fields(l)
+			if len(parts) >= 3 {
+				resp.Server = parts[2]
+			}
+			continue
+		}
+		if strings.HasPrefix(l, ";") {
+			continue
+		}
+
+		fields := strings.Fields(l)
+		if len(fields) >= 5 {
+			ttl, _ := strconv.Atoi(fields[1])
+			resp.Records = append(resp.Records, DNSRecord{
+				Name:  fields[0],
+				TTL:   ttl,
+				Class: fields[2],
+				Type:  fields[3],
+				Value: strings.Join(fields[4:], " "),
+			})
+		}
+	}
+
+	return resp, nil
+}
+
+// RunPortProbe tests TCP port connectivity and measures latency
+func (m *Manager) RunPortProbe(ctx context.Context, req PortProbeRequest) (*PortProbeResponse, error) {
+	target, err := sanitizeTarget(req.Target)
+	if err != nil {
+		return nil, err
+	}
+
+	if req.Port <= 0 || req.Port > 65535 {
+		return nil, fmt.Errorf("invalid port number: must be between 1 and 65535")
+	}
+
+	timeoutSec := req.Timeout
+	if timeoutSec <= 0 {
+		timeoutSec = 3
+	}
+	if timeoutSec > 10 {
+		timeoutSec = 10
+	}
+
+	serviceMap := map[int]string{
+		21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 53: "DNS",
+		80: "HTTP", 110: "POP3", 143: "IMAP", 179: "BGP", 443: "HTTPS",
+		587: "Submission", 993: "IMAPS", 995: "POP3S", 1194: "OpenVPN",
+		3389: "RDP", 5060: "SIP", 8080: "HTTP-Alt", 8291: "Winbox (MikroTik)",
+		8443: "HTTPS-Alt", 8006: "Proxmox VE",
+	}
+
+	addr := net.JoinHostPort(target, strconv.Itoa(req.Port))
+	start := time.Now()
+
+	d := net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
+	conn, dialErr := d.DialContext(ctx, "tcp", addr)
+	elapsed := float64(time.Since(start).Microseconds()) / 1000.0
+
+	resp := &PortProbeResponse{
+		Target:    target,
+		Port:      req.Port,
+		LatencyMs: elapsed,
+		Service:   serviceMap[req.Port],
+	}
+
+	if dialErr != nil {
+		resp.Open = false
+		resp.Error = dialErr.Error()
+		resp.RawOutput = fmt.Sprintf("Failed to connect to %s: %v", addr, dialErr)
+	} else {
+		_ = conn.Close()
+		resp.Open = true
+		resp.RawOutput = fmt.Sprintf("Successfully connected to %s (%s) in %.2f ms (TCP handshake OK)", addr, resp.Service, elapsed)
+	}
+
+	return resp, nil
+}
+
+// RunMTR executes MTR (My Traceroute) report
+func (m *Manager) RunMTR(ctx context.Context, req MTRRequest) (*MTRResponse, error) {
+	target, err := sanitizeTarget(req.Target)
+	if err != nil {
+		return nil, err
+	}
+
+	count := req.Count
+	if count <= 0 {
+		count = 3
+	}
+	if count > 10 {
+		count = 10
+	}
+
+	cmd := exec.CommandContext(ctx, "mtr", "-n", "-r", "-c", strconv.Itoa(count), target)
+	outBytes, _ := cmd.CombinedOutput()
+	outStr := string(outBytes)
+
+	resp := &MTRResponse{
+		Target:    target,
+		Hops:      []MTRHop{},
+		RawOutput: outStr,
+	}
+
+	lines := strings.Split(outStr, "\n")
+	for _, line := range lines {
+		matches := mtrHopRegex.FindStringSubmatch(line)
+		if len(matches) >= 10 {
+			hopNum, _ := strconv.Atoi(matches[1])
+			host := matches[2]
+			loss, _ := strconv.ParseFloat(matches[3], 64)
+			sent, _ := strconv.Atoi(matches[4])
+			last, _ := strconv.ParseFloat(matches[5], 64)
+			avg, _ := strconv.ParseFloat(matches[6], 64)
+			best, _ := strconv.ParseFloat(matches[7], 64)
+			wrst, _ := strconv.ParseFloat(matches[8], 64)
+			stdev, _ := strconv.ParseFloat(matches[9], 64)
+
+			resp.Hops = append(resp.Hops, MTRHop{
+				Hop:     hopNum,
+				Host:    host,
+				LossPct: loss,
+				Sent:    sent,
+				LastMs:  last,
+				AvgMs:   avg,
+				BestMs:  best,
+				WrstMs:  wrst,
+				StDevMs: stdev,
+			})
+		}
+	}
+
+	return resp, nil
+}

+ 120 - 0
internal/tools/types.go

@@ -0,0 +1,120 @@
+package tools
+
+type PingRequest struct {
+	Target    string `json:"target"`
+	Count     int    `json:"count"`      // 1 - 20, default 4
+	Size      int    `json:"size"`       // default 56
+	Interface string `json:"interface"` // optional source interface
+	Timeout   int    `json:"timeout"`   // seconds, default 5
+	DoNotFrag bool   `json:"dont_frag"`
+}
+
+type PingReply struct {
+	Seq    int     `json:"seq"`
+	Bytes  int     `json:"bytes"`
+	From   string  `json:"from"`
+	TTL    int     `json:"ttl"`
+	TimeMs float64 `json:"time_ms"`
+}
+
+type PingResponse struct {
+	Target        string      `json:"target"`
+	PacketsSent   int         `json:"packets_sent"`
+	PacketsRecv   int         `json:"packets_recv"`
+	PacketLossPct float64     `json:"packet_loss_pct"`
+	MinRTT        float64     `json:"min_rtt_ms"`
+	AvgRTT        float64     `json:"avg_rtt_ms"`
+	MaxRTT        float64     `json:"max_rtt_ms"`
+	MdevRTT       float64     `json:"mdev_rtt_ms"`
+	Replies       []PingReply `json:"replies"`
+	RawOutput     string      `json:"raw_output"`
+	Status        string      `json:"status"` // "ok", "partial", "timeout", "error"
+	Error         string      `json:"error,omitempty"`
+}
+
+type TracerouteRequest struct {
+	Target    string `json:"target"`
+	MaxHops   int    `json:"max_hops"`   // default 15, max 30
+	Resolve   bool   `json:"resolve"`    // DNS resolve IPs
+	Interface string `json:"interface"` // source dev
+	Protocol  string `json:"protocol"`  // "icmp", "udp"
+}
+
+type TracerouteHop struct {
+	Hop     int      `json:"hop"`
+	Host    string   `json:"host"`
+	IP      string   `json:"ip"`
+	TimesMs []string `json:"times_ms"`
+	Status  string   `json:"status"` // "ok", "timeout"
+}
+
+type TracerouteResponse struct {
+	Target    string          `json:"target"`
+	Hops      []TracerouteHop `json:"hops"`
+	RawOutput string          `json:"raw_output"`
+	Error     string          `json:"error,omitempty"`
+}
+
+type DNSRequest struct {
+	Domain string `json:"domain"`
+	Type   string `json:"type"`   // A, AAAA, MX, TXT, NS, CNAME, ANY
+	Server string `json:"server"` // optional DNS server IP
+}
+
+type DNSRecord struct {
+	Name  string `json:"name"`
+	TTL   int    `json:"ttl"`
+	Class string `json:"class"`
+	Type  string `json:"type"`
+	Value string `json:"value"`
+}
+
+type DNSResponse struct {
+	Domain      string      `json:"domain"`
+	Type        string      `json:"type"`
+	Server      string      `json:"server"`
+	QueryTimeMs int         `json:"query_time_ms"`
+	Records     []DNSRecord `json:"records"`
+	RawOutput   string      `json:"raw_output"`
+	Error       string      `json:"error,omitempty"`
+}
+
+type PortProbeRequest struct {
+	Target  string `json:"target"`
+	Port    int    `json:"port"`
+	Timeout int    `json:"timeout"` // default 2s
+}
+
+type PortProbeResponse struct {
+	Target    string  `json:"target"`
+	Port      int     `json:"port"`
+	Open      bool    `json:"open"`
+	LatencyMs float64 `json:"latency_ms"`
+	Service   string  `json:"service,omitempty"`
+	RawOutput string  `json:"raw_output"`
+	Error     string  `json:"error,omitempty"`
+}
+
+type MTRRequest struct {
+	Target string `json:"target"`
+	Count  int    `json:"count"` // default 3
+}
+
+type MTRHop struct {
+	Hop     int     `json:"hop"`
+	Host    string  `json:"host"`
+	LossPct float64 `json:"loss_pct"`
+	Sent    int     `json:"sent"`
+	LastMs  float64 `json:"last_ms"`
+	AvgMs   float64 `json:"avg_ms"`
+	BestMs  float64 `json:"best_ms"`
+	WrstMs  float64 `json:"wrst_ms"`
+	StDevMs float64 `json:"stdev_ms"`
+}
+
+type MTRResponse struct {
+	Target    string   `json:"target"`
+	Hops      []MTRHop `json:"hops"`
+	RawOutput string   `json:"raw_output"`
+	Error     string   `json:"error,omitempty"`
+}

+ 471 - 1
internal/web/static/app.js

@@ -41,7 +41,8 @@ let state = {
   policyRules: [],
   routingTables: [],
   routingFilters: [],
-  filterChainSelected: 'all'
+  filterChainSelected: 'all',
+  activeToolsSubTab: 'ping'
 };
 
 const API_BASE = '/api';
@@ -113,6 +114,7 @@ function navigate(tabName) {
     ipsec: { title: 'IPSec (IKEv2) strongSwan', sub: 'Enterprise Site-to-Site & Roadwarrior IPSec controller' },
     l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' },
     pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' },
+    tools: { title: 'Troubleshoot & Diagnostic Tools (/tool)', sub: 'RouterOS-style Ping, Traceroute, MTR Telemetry, DNS resolution, and TCP Port probing' },
     persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
   };
 
@@ -152,6 +154,7 @@ function navigate(tabName) {
   if (tabName === 'ipsec') loadIPSec();
   if (tabName === 'l2tp') loadL2TP();
   if (tabName === 'pptp') loadPPTP();
+  if (tabName === 'tools') loadToolsView();
 }
 
 // Fetch all core system, network and VPN telemetry
@@ -7038,3 +7041,470 @@ async function toggleRoutingFilterState(id, disabled) {
   }
 }
 
+// ==========================================
+// TROUBLESHOOT & DIAGNOSTIC TOOLS (/tool)
+// ==========================================
+
+function loadToolsView() {
+  populateToolsInterfaces();
+  switchToolsSubTab(state.activeToolsSubTab || 'ping');
+}
+
+function populateToolsInterfaces() {
+  const pingSel = document.getElementById('tools-ping-iface');
+  const traceSel = document.getElementById('tools-trace-iface');
+  if (!pingSel || !traceSel) return;
+
+  const currentPing = pingSel.value;
+  const currentTrace = traceSel.value;
+
+  let opts = '<option value="">Default (System Routing)</option>';
+  if (Array.isArray(state.interfaces)) {
+    state.interfaces.forEach(iface => {
+      if (iface.name) {
+        opts += `<option value="${escapeHtml(iface.name)}">${escapeHtml(iface.name)}${iface.ip_address ? ' (' + escapeHtml(iface.ip_address) + ')' : ''}</option>`;
+      }
+    });
+  }
+
+  pingSel.innerHTML = opts;
+  traceSel.innerHTML = opts;
+
+  if (currentPing) pingSel.value = currentPing;
+  if (currentTrace) traceSel.value = currentTrace;
+}
+
+function switchToolsSubTab(subTab) {
+  state.activeToolsSubTab = subTab;
+  const tabs = ['ping', 'traceroute', 'mtr', 'dns', 'port'];
+
+  tabs.forEach(t => {
+    const btn = document.getElementById(`tools-subtab-btn-${t}`);
+    const view = document.getElementById(`tools-view-${t}`);
+    if (btn) {
+      if (t === subTab) {
+        btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-amber-400 text-amber-300 transition flex items-center gap-2 whitespace-nowrap';
+      } else {
+        btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
+      }
+    }
+    if (view) {
+      if (t === subTab) {
+        view.classList.remove('hidden');
+      } else {
+        view.classList.add('hidden');
+      }
+    }
+  });
+}
+
+function setPortPreset(port) {
+  const input = document.getElementById('tools-port-num');
+  if (input) input.value = port;
+}
+
+async function executePing(e) {
+  if (e) e.preventDefault();
+  const target = document.getElementById('tools-ping-target')?.value?.trim();
+  const count = parseInt(document.getElementById('tools-ping-count')?.value || '4', 10);
+  const size = parseInt(document.getElementById('tools-ping-size')?.value || '56', 10);
+  const timeout = parseInt(document.getElementById('tools-ping-timeout')?.value || '5', 10);
+  const iface = document.getElementById('tools-ping-iface')?.value || '';
+  const dontFrag = document.getElementById('tools-ping-df')?.checked || false;
+
+  if (!target) {
+    showToast('Input Required', 'Please enter a target host or IP', 'warning');
+    return;
+  }
+
+  const btn = document.getElementById('tools-ping-btn');
+  const spinner = document.getElementById('tools-ping-spinner');
+  if (btn) btn.disabled = true;
+  if (spinner) spinner.classList.remove('hidden');
+
+  try {
+    const res = await fetch(`${API_BASE}/tools/ping`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        target,
+        count,
+        size,
+        timeout,
+        interface: iface,
+        dont_frag: dontFrag
+      })
+    });
+
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Ping Error', data.error || 'Failed to execute ping', 'error');
+      return;
+    }
+
+    const resCard = document.getElementById('tools-ping-results');
+    if (resCard) resCard.classList.remove('hidden');
+
+    document.getElementById('tools-ping-stat-sent').innerText = data.packets_sent ?? 0;
+    document.getElementById('tools-ping-stat-rcv').innerText = data.packets_recv ?? 0;
+    
+    const lossEl = document.getElementById('tools-ping-stat-loss');
+    if (lossEl) {
+      const loss = data.packet_loss_pct ?? 0;
+      lossEl.innerText = `${loss}%`;
+      lossEl.className = loss === 0 ? 'text-lg font-bold font-mono text-emerald-400' : (loss < 50 ? 'text-lg font-bold font-mono text-amber-400' : 'text-lg font-bold font-mono text-rose-400');
+    }
+
+    document.getElementById('tools-ping-stat-min').innerText = data.min_rtt_ms ? `${data.min_rtt_ms.toFixed(1)} ms` : '--';
+    document.getElementById('tools-ping-stat-avg').innerText = data.avg_rtt_ms ? `${data.avg_rtt_ms.toFixed(1)} ms` : '--';
+    document.getElementById('tools-ping-stat-max').innerText = data.max_rtt_ms ? `${data.max_rtt_ms.toFixed(1)} ms` : '--';
+    document.getElementById('tools-ping-stat-jitter').innerText = data.mdev_rtt_ms ? `${data.mdev_rtt_ms.toFixed(2)} ms` : '--';
+
+    const tbody = document.getElementById('tools-ping-table-body');
+    if (tbody) {
+      if (Array.isArray(data.replies) && data.replies.length > 0) {
+        tbody.innerHTML = data.replies.map(r => `
+          <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs">
+            <td class="p-3 text-slate-300">#${r.seq}</td>
+            <td class="p-3 font-semibold text-white">${escapeHtml(r.from)}</td>
+            <td class="p-3 text-slate-400">${r.bytes} bytes</td>
+            <td class="p-3 text-slate-300">${r.ttl}</td>
+            <td class="p-3 font-bold text-amber-300">${r.time_ms.toFixed(2)} ms</td>
+            <td class="p-3"><span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60">REPLY</span></td>
+          </tr>
+        `).join('');
+      } else {
+        tbody.innerHTML = `<tr><td colspan="6" class="p-4 text-center text-xs text-rose-400 font-mono">No replies received (100% packet loss or timed out)</td></tr>`;
+      }
+    }
+
+    const rawEl = document.getElementById('tools-ping-raw');
+    if (rawEl) rawEl.innerText = data.raw_output || 'No output';
+
+    showToast('Ping Finished', `Sent ${data.packets_sent}, received ${data.packets_recv} (${data.packet_loss_pct}% loss)`, data.packet_loss_pct === 0 ? 'success' : 'info');
+  } catch (err) {
+    showToast('Ping Error', err.message, 'error');
+  } finally {
+    if (btn) btn.disabled = false;
+    if (spinner) spinner.classList.add('hidden');
+  }
+}
+
+async function executeTraceroute(e) {
+  if (e) e.preventDefault();
+  const target = document.getElementById('tools-trace-target')?.value?.trim();
+  const maxHops = parseInt(document.getElementById('tools-trace-maxhops')?.value || '15', 10);
+  const timeout = parseInt(document.getElementById('tools-trace-timeout')?.value || '1', 10);
+  const protocol = document.getElementById('tools-trace-protocol')?.value || 'icmp';
+  const iface = document.getElementById('tools-trace-iface')?.value || '';
+  const resolve = document.getElementById('tools-trace-resolve')?.checked || false;
+
+  if (!target) {
+    showToast('Input Required', 'Please enter a target host or IP', 'warning');
+    return;
+  }
+
+  const btn = document.getElementById('tools-trace-btn');
+  const spinner = document.getElementById('tools-trace-spinner');
+  if (btn) btn.disabled = true;
+  if (spinner) spinner.classList.remove('hidden');
+
+  try {
+    const res = await fetch(`${API_BASE}/tools/traceroute`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        target,
+        max_hops: maxHops,
+        timeout,
+        protocol,
+        interface: iface,
+        resolve
+      })
+    });
+
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Traceroute Error', data.error || 'Failed to execute traceroute', 'error');
+      return;
+    }
+
+    const resCard = document.getElementById('tools-trace-results');
+    if (resCard) resCard.classList.remove('hidden');
+
+    const hopsCount = Array.isArray(data.hops) ? data.hops.length : 0;
+    const statHops = document.getElementById('tools-trace-stat-hops');
+    if (statHops) statHops.innerText = hopsCount;
+
+    const statStatus = document.getElementById('tools-trace-stat-status');
+    if (statStatus) {
+      statStatus.innerText = hopsCount > 0 ? 'Trace Complete' : 'No Hops Discovered';
+      statStatus.className = hopsCount > 0 ? 'text-xs font-bold font-mono text-emerald-400' : 'text-xs font-bold font-mono text-rose-400';
+    }
+
+    const tbody = document.getElementById('tools-trace-table-body');
+    if (tbody) {
+      if (hopsCount > 0) {
+        tbody.innerHTML = data.hops.map(h => {
+          const isTimeout = h.status === 'timeout' || (!h.ip && !h.host);
+          const rttDisplay = Array.isArray(h.times_ms) && h.times_ms.length > 0 ? h.times_ms.join(', ') : (isTimeout ? '* * *' : '--');
+          return `
+            <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs ${isTimeout ? 'opacity-60' : ''}">
+              <td class="p-3 text-slate-300 font-bold">${h.hop}</td>
+              <td class="p-3 font-semibold ${isTimeout ? 'text-slate-500 italic' : 'text-cyan-300'}">${escapeHtml(h.ip || (isTimeout ? 'Request timed out' : ''))}</td>
+              <td class="p-3 text-slate-400">${escapeHtml(h.host || '--')}</td>
+              <td class="p-3 font-bold ${isTimeout ? 'text-slate-500' : 'text-amber-300'}">${escapeHtml(rttDisplay)}</td>
+              <td class="p-3">
+                ${isTimeout 
+                  ? '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">TIMEOUT</span>' 
+                  : '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60">REACHABLE</span>'}
+              </td>
+            </tr>
+          `;
+        }).join('');
+      } else {
+        tbody.innerHTML = `<tr><td colspan="5" class="p-4 text-center text-xs text-rose-400 font-mono">No route or hops discovered</td></tr>`;
+      }
+    }
+
+    const rawEl = document.getElementById('tools-trace-raw');
+    if (rawEl) rawEl.innerText = data.raw_output || 'No output';
+
+    showToast('Traceroute Finished', `Traced ${hopsCount} hops to ${target}`, 'info');
+  } catch (err) {
+    showToast('Traceroute Error', err.message, 'error');
+  } finally {
+    if (btn) btn.disabled = false;
+    if (spinner) spinner.classList.add('hidden');
+  }
+}
+
+async function executeMTR(e) {
+  if (e) e.preventDefault();
+  const target = document.getElementById('tools-mtr-target')?.value?.trim();
+  const cycles = parseInt(document.getElementById('tools-mtr-cycles')?.value || '3', 10);
+
+  if (!target) {
+    showToast('Input Required', 'Please enter a target host or IP', 'warning');
+    return;
+  }
+
+  const btn = document.getElementById('tools-mtr-btn');
+  const spinner = document.getElementById('tools-mtr-spinner');
+  if (btn) btn.disabled = true;
+  if (spinner) spinner.classList.remove('hidden');
+
+  try {
+    const res = await fetch(`${API_BASE}/tools/mtr`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ target, count: cycles })
+    });
+
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('MTR Error', data.error || 'Failed to execute MTR', 'error');
+      return;
+    }
+
+    const resCard = document.getElementById('tools-mtr-results');
+    if (resCard) resCard.classList.remove('hidden');
+
+    const hopsCount = Array.isArray(data.hops) ? data.hops.length : 0;
+    const statHops = document.getElementById('tools-mtr-stat-hops');
+    if (statHops) statHops.innerText = hopsCount;
+
+    const statDest = document.getElementById('tools-mtr-stat-dest');
+    if (statDest) statDest.innerText = data.target || target;
+
+    const tbody = document.getElementById('tools-mtr-table-body');
+    if (tbody) {
+      if (hopsCount > 0) {
+        tbody.innerHTML = data.hops.map(h => {
+          const lossClass = h.loss_pct === 0 ? 'text-emerald-400 font-bold' : (h.loss_pct < 20 ? 'text-amber-400 font-bold' : 'text-rose-400 font-bold');
+          return `
+            <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs">
+              <td class="p-3 text-slate-300 font-bold">${h.hop}</td>
+              <td class="p-3 font-semibold text-white">${escapeHtml(h.host || '--')}</td>
+              <td class="p-3 ${lossClass}">${h.loss_pct.toFixed(1)}%</td>
+              <td class="p-3 text-slate-300">${h.sent}</td>
+              <td class="p-3 text-slate-300">${h.last_ms.toFixed(1)} ms</td>
+              <td class="p-3 font-bold text-amber-300">${h.avg_ms.toFixed(1)} ms</td>
+              <td class="p-3 text-emerald-400">${h.best_ms.toFixed(1)} ms</td>
+              <td class="p-3 text-rose-400">${h.wrst_ms.toFixed(1)} ms</td>
+              <td class="p-3 text-slate-400">${h.stdev_ms.toFixed(1)}</td>
+            </tr>
+          `;
+        }).join('');
+      } else {
+        tbody.innerHTML = `<tr><td colspan="9" class="p-4 text-center text-xs text-rose-400 font-mono">No MTR data collected</td></tr>`;
+      }
+    }
+
+    const rawEl = document.getElementById('tools-mtr-raw');
+    if (rawEl) rawEl.innerText = data.raw_output || 'No output';
+
+    showToast('MTR Finished', `Report completed (${hopsCount} hops discovered)`, 'info');
+  } catch (err) {
+    showToast('MTR Error', err.message, 'error');
+  } finally {
+    if (btn) btn.disabled = false;
+    if (spinner) spinner.classList.add('hidden');
+  }
+}
+
+async function executeDNS(e) {
+  if (e) e.preventDefault();
+  const domain = document.getElementById('tools-dns-domain')?.value?.trim();
+  const type = document.getElementById('tools-dns-type')?.value || 'A';
+  const server = document.getElementById('tools-dns-server')?.value?.trim() || '';
+
+  if (!domain) {
+    showToast('Input Required', 'Please enter a hostname or domain', 'warning');
+    return;
+  }
+
+  const btn = document.getElementById('tools-dns-btn');
+  const spinner = document.getElementById('tools-dns-spinner');
+  if (btn) btn.disabled = true;
+  if (spinner) spinner.classList.remove('hidden');
+
+  try {
+    const res = await fetch(`${API_BASE}/tools/dns`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ domain, type, server })
+    });
+
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('DNS Error', data.error || 'Failed to resolve DNS query', 'error');
+      return;
+    }
+
+    const resCard = document.getElementById('tools-dns-results');
+    if (resCard) resCard.classList.remove('hidden');
+
+    const statTime = document.getElementById('tools-dns-stat-time');
+    if (statTime) statTime.innerText = `${data.query_time_ms ?? 0} ms`;
+
+    const statServer = document.getElementById('tools-dns-stat-server');
+    if (statServer) statServer.innerText = data.server || (server || 'System Resolver');
+
+    const tbody = document.getElementById('tools-dns-table-body');
+    if (tbody) {
+      if (Array.isArray(data.records) && data.records.length > 0) {
+        tbody.innerHTML = data.records.map(r => `
+          <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs">
+            <td class="p-3 font-semibold text-white">${escapeHtml(r.name)}</td>
+            <td class="p-3 text-slate-400">${r.ttl}s</td>
+            <td class="p-3"><span class="px-2 py-0.5 rounded text-[10px] font-bold bg-cyan-950 text-cyan-300 border border-cyan-800/60">${escapeHtml(r.type)}</span></td>
+            <td class="p-3 font-bold text-amber-300 break-all">${escapeHtml(r.value)}</td>
+          </tr>
+        `).join('');
+      } else {
+        tbody.innerHTML = `<tr><td colspan="4" class="p-4 text-center text-xs text-amber-400 font-mono">No answer records returned for ${escapeHtml(type)} query</td></tr>`;
+      }
+    }
+
+    const rawEl = document.getElementById('tools-dns-raw');
+    if (rawEl) rawEl.innerText = data.raw_output || 'No output';
+
+    showToast('DNS Lookup Done', `Resolved ${domain} in ${data.query_time_ms} ms`, 'success');
+  } catch (err) {
+    showToast('DNS Error', err.message, 'error');
+  } finally {
+    if (btn) btn.disabled = false;
+    if (spinner) spinner.classList.add('hidden');
+  }
+}
+
+async function executePortProbe(e) {
+  if (e) e.preventDefault();
+  const target = document.getElementById('tools-port-target')?.value?.trim();
+  const port = parseInt(document.getElementById('tools-port-num')?.value || '80', 10);
+  const timeout = parseInt(document.getElementById('tools-port-timeout')?.value || '3', 10);
+
+  if (!target || !port) {
+    showToast('Input Required', 'Please enter a target host and TCP port', 'warning');
+    return;
+  }
+
+  const btn = document.getElementById('tools-port-btn');
+  const spinner = document.getElementById('tools-port-spinner');
+  if (btn) btn.disabled = true;
+  if (spinner) spinner.classList.remove('hidden');
+
+  try {
+    const res = await fetch(`${API_BASE}/tools/port-probe`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ target, port, timeout })
+    });
+
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Probe Error', data.error || 'Failed to probe TCP port', 'error');
+      return;
+    }
+
+    const resCard = document.getElementById('tools-port-results');
+    if (resCard) resCard.classList.remove('hidden');
+
+    const banner = document.getElementById('tools-port-banner');
+    const icon = document.getElementById('tools-port-icon');
+    const title = document.getElementById('tools-port-title');
+    const sub = document.getElementById('tools-port-sub');
+    const latency = document.getElementById('tools-port-latency');
+
+    if (data.open) {
+      if (banner) banner.className = 'rounded-xl p-5 border bg-emerald-950/30 border-emerald-800/50 flex items-center justify-between';
+      if (icon) {
+        icon.className = 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm bg-emerald-500/20 text-emerald-400 border border-emerald-500/40';
+        icon.innerText = '✓';
+      }
+      if (title) {
+        title.className = 'text-sm font-bold text-emerald-300';
+        title.innerText = `PORT ${port} OPEN / LISTENING`;
+      }
+      if (sub) {
+        sub.className = 'text-xs mt-0.5 font-mono text-emerald-400/80';
+        sub.innerText = `TCP 3-way handshake established with ${escapeHtml(target)}:${port}${data.service ? ' (' + escapeHtml(data.service) + ')' : ''}`;
+      }
+      if (latency) {
+        latency.className = 'text-base font-bold font-mono text-emerald-300';
+        latency.innerText = `${data.latency_ms.toFixed(2)} ms`;
+      }
+    } else {
+      if (banner) banner.className = 'rounded-xl p-5 border bg-rose-950/30 border-rose-800/50 flex items-center justify-between';
+      if (icon) {
+        icon.className = 'w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm bg-rose-500/20 text-rose-400 border border-rose-500/40';
+        icon.innerText = '✕';
+      }
+      if (title) {
+        title.className = 'text-sm font-bold text-rose-300';
+        title.innerText = `PORT ${port} CLOSED / FILTERED`;
+      }
+      if (sub) {
+        sub.className = 'text-xs mt-0.5 font-mono text-rose-400/80';
+        sub.innerText = `Connection failed or timed out to ${escapeHtml(target)}:${port}`;
+      }
+      if (latency) {
+        latency.className = 'text-base font-bold font-mono text-slate-500';
+        latency.innerText = '--';
+      }
+    }
+
+    const rawEl = document.getElementById('tools-port-raw');
+    if (rawEl) rawEl.innerText = data.raw_output || (data.open ? `Successfully connected to ${target}:${port} in ${data.latency_ms} ms` : `Connection failed: ${data.error || 'timed out or connection refused'}`);
+
+    showToast('Probe Complete', `Port ${port} is ${data.open ? 'OPEN' : 'CLOSED'}`, data.open ? 'success' : 'warning');
+  } catch (err) {
+    showToast('Probe Error', err.message, 'error');
+  } finally {
+    if (btn) btn.disabled = false;
+    if (spinner) spinner.classList.add('hidden');
+  }
+}
+
+

+ 409 - 1
internal/web/static/index.html

@@ -260,6 +260,21 @@
           <span id="badge-eoip-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <!-- CATEGORY: DIAGNOSTICS & TOOLS -->
+        <div class="px-3 pt-3 pb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">Diagnostics</div>
+
+        <a href="#tools" onclick="navigate('tools')" id="nav-tools"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+            </svg>
+            <span>Troubleshoot (/tool)</span>
+          </div>
+          <span class="text-[10px] bg-amber-950 text-amber-300 border border-amber-800/40 px-1.5 py-0.2 rounded-full font-mono">Tools</span>
+        </a>
+
         <!-- CATEGORY: SYSTEM -->
         <div class="px-3 pt-3 pb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">System</div>
 
@@ -2339,6 +2354,399 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: TROUBLESHOOT TOOLS (/tool) ================= -->
+        <section id="section-tools" class="hidden space-y-6">
+          <!-- Top Banner -->
+          <div class="bg-gradient-to-r from-amber-950/40 via-slate-900 to-slate-900 border border-amber-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-amber-500/20 text-amber-300 border border-amber-500/30 font-semibold">/tool</span>
+                <h2 class="text-base font-bold text-white">Troubleshoot & Diagnostic Tools</h2>
+              </div>
+              <p class="text-xs text-slate-400">RouterOS-style network diagnostics: ICMP Ping, Traceroute, MTR telemetry, DNS Resolution, and TCP Port Probing</p>
+            </div>
+          </div>
+
+          <!-- Tools Subtabs Navigation -->
+          <div class="flex items-center gap-1 border-b border-slate-800 pb-0 overflow-x-auto">
+            <button id="tools-subtab-btn-ping" onclick="switchToolsSubTab('ping')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-amber-400 text-amber-300 transition flex items-center gap-2 whitespace-nowrap">
+              <span>Ping (/tool ping)</span>
+            </button>
+            <button id="tools-subtab-btn-traceroute" onclick="switchToolsSubTab('traceroute')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>Traceroute (/tool traceroute)</span>
+            </button>
+            <button id="tools-subtab-btn-mtr" onclick="switchToolsSubTab('mtr')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>MTR Telemetry</span>
+            </button>
+            <button id="tools-subtab-btn-dns" onclick="switchToolsSubTab('dns')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>DNS Lookup (/tool resolve)</span>
+            </button>
+            <button id="tools-subtab-btn-port" onclick="switchToolsSubTab('port')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>Port Probe (TCP Check)</span>
+            </button>
+          </div>
+
+          <!-- ================= SUBTAB: PING ================= -->
+          <div id="tools-view-ping" class="space-y-5">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
+              <form id="tools-ping-form" onsubmit="executePing(event)" class="space-y-4">
+                <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+                  <div class="md:col-span-2">
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Target Host or IP *</label>
+                    <input type="text" id="tools-ping-target" required placeholder="e.g. 8.8.8.8, 1.1.1.1, google.com" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Count</label>
+                    <select id="tools-ping-count" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                      <option value="4" selected>4 packets</option>
+                      <option value="5">5 packets</option>
+                      <option value="10">10 packets</option>
+                      <option value="20">20 packets</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Source Interface</label>
+                    <select id="tools-ping-iface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                      <option value="">Auto (Default Route)</option>
+                    </select>
+                  </div>
+                </div>
+
+                <div class="flex flex-wrap items-center justify-between gap-4 pt-2">
+                  <div class="flex items-center space-x-6 text-xs">
+                    <label class="flex items-center space-x-2 text-slate-300 cursor-pointer">
+                      <input type="checkbox" id="tools-ping-df" class="rounded text-amber-500 bg-slate-800 border-slate-700">
+                      <span>Don't Fragment (DF bit)</span>
+                    </label>
+                    <div class="flex items-center space-x-2">
+                      <span class="text-slate-400">Size (bytes):</span>
+                      <input type="number" id="tools-ping-size" value="56" min="1" max="65500" class="w-20 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white font-mono">
+                    </div>
+                  </div>
+
+                  <button type="submit" id="tools-ping-btn" class="px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2">
+                    <span id="tools-ping-spinner" class="hidden">↻</span>
+                    <span>Start Ping</span>
+                  </button>
+                </div>
+              </form>
+            </div>
+
+            <!-- Ping Results Container -->
+            <div id="tools-ping-results" class="hidden space-y-4">
+              <!-- KPI Summary Cards -->
+              <div class="grid grid-cols-2 md:grid-cols-5 gap-3">
+                <div class="bg-slate-900 border border-slate-800 rounded-xl p-3 text-center">
+                  <div class="text-[10px] text-slate-400 uppercase">Packets Sent</div>
+                  <div id="tools-ping-stat-sent" class="text-base font-bold text-white font-mono mt-1">0</div>
+                </div>
+                <div class="bg-slate-900 border border-slate-800 rounded-xl p-3 text-center">
+                  <div class="text-[10px] text-slate-400 uppercase">Received</div>
+                  <div id="tools-ping-stat-recv" class="text-base font-bold text-emerald-400 font-mono mt-1">0</div>
+                </div>
+                <div class="bg-slate-900 border border-slate-800 rounded-xl p-3 text-center">
+                  <div class="text-[10px] text-slate-400 uppercase">Packet Loss</div>
+                  <div id="tools-ping-stat-loss" class="text-base font-bold font-mono mt-1">0%</div>
+                </div>
+                <div class="bg-slate-900 border border-slate-800 rounded-xl p-3 text-center">
+                  <div class="text-[10px] text-slate-400 uppercase">Avg RTT</div>
+                  <div id="tools-ping-stat-avg" class="text-base font-bold text-cyan-400 font-mono mt-1">0 ms</div>
+                </div>
+                <div class="bg-slate-900 border border-slate-800 rounded-xl p-3 text-center">
+                  <div class="text-[10px] text-slate-400 uppercase">Min / Max</div>
+                  <div id="tools-ping-stat-minmax" class="text-xs font-bold text-purple-300 font-mono mt-1.5">-</div>
+                </div>
+              </div>
+
+              <!-- Replies Table -->
+              <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+                <div class="p-3 border-b border-slate-800 flex items-center justify-between">
+                  <span class="text-xs font-bold text-white">ICMP Echo Replies</span>
+                  <span id="tools-ping-status-badge" class="px-2 py-0.5 rounded text-[10px] font-mono font-semibold"></span>
+                </div>
+                <div class="overflow-x-auto">
+                  <table class="w-full text-left text-xs font-mono">
+                    <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                      <tr>
+                        <th class="py-2 px-3"># Seq</th>
+                        <th class="py-2 px-3">Responder</th>
+                        <th class="py-2 px-3">Bytes</th>
+                        <th class="py-2 px-3">TTL</th>
+                        <th class="py-2 px-3">Latency (RTT)</th>
+                        <th class="py-2 px-3 text-right">Status</th>
+                      </tr>
+                    </thead>
+                    <tbody id="tools-ping-tbody" class="divide-y divide-slate-800/80"></tbody>
+                  </table>
+                </div>
+              </div>
+
+              <!-- Raw CLI Drawer -->
+              <details class="bg-slate-900/60 border border-slate-800/80 rounded-xl p-3 text-xs">
+                <summary class="cursor-pointer text-slate-400 font-medium hover:text-white">Raw CLI Console Output</summary>
+                <pre id="tools-ping-raw" class="mt-2 bg-slate-950 p-3 rounded font-mono text-[11px] text-slate-300 overflow-x-auto border border-slate-800 leading-relaxed"></pre>
+              </details>
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB: TRACEROUTE ================= -->
+          <div id="tools-view-traceroute" class="hidden space-y-5">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
+              <form id="tools-traceroute-form" onsubmit="executeTraceroute(event)" class="space-y-4">
+                <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+                  <div class="md:col-span-2">
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Target Host or IP *</label>
+                    <input type="text" id="tools-tr-target" required placeholder="e.g. 1.1.1.1 or google.com" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Max Hops</label>
+                    <input type="number" id="tools-tr-maxhops" value="15" min="1" max="30" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Protocol</label>
+                    <select id="tools-tr-proto" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                      <option value="icmp" selected>ICMP Echo (-I)</option>
+                      <option value="udp">UDP Probe</option>
+                    </select>
+                  </div>
+                </div>
+
+                <div class="flex items-center justify-between pt-2">
+                  <label class="flex items-center space-x-2 text-xs text-slate-300 cursor-pointer">
+                    <input type="checkbox" id="tools-tr-resolve" class="rounded text-amber-500 bg-slate-800 border-slate-700">
+                    <span>Resolve Hostnames (Reverse DNS)</span>
+                  </label>
+
+                  <button type="submit" id="tools-tr-btn" class="px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2">
+                    <span id="tools-tr-spinner" class="hidden">↻</span>
+                    <span>Start Traceroute</span>
+                  </button>
+                </div>
+              </form>
+            </div>
+
+            <div id="tools-tr-results" class="hidden space-y-4">
+              <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+                <div class="p-3 border-b border-slate-800 flex items-center justify-between">
+                  <span class="text-xs font-bold text-white">Route Hops</span>
+                  <span id="tools-tr-hops-count" class="text-xs font-mono text-slate-400">0 Hops</span>
+                </div>
+                <div class="overflow-x-auto">
+                  <table class="w-full text-left text-xs font-mono">
+                    <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                      <tr>
+                        <th class="py-2.5 px-3 w-16">Hop #</th>
+                        <th class="py-2.5 px-3">Gateway Host / IP</th>
+                        <th class="py-2.5 px-3">RTT 1</th>
+                        <th class="py-2.5 px-3">RTT 2</th>
+                        <th class="py-2.5 px-3 text-right">Status</th>
+                      </tr>
+                    </thead>
+                    <tbody id="tools-tr-tbody" class="divide-y divide-slate-800/80"></tbody>
+                  </table>
+                </div>
+              </div>
+
+              <details class="bg-slate-900/60 border border-slate-800/80 rounded-xl p-3 text-xs">
+                <summary class="cursor-pointer text-slate-400 font-medium hover:text-white">Raw CLI Console Output</summary>
+                <pre id="tools-tr-raw" class="mt-2 bg-slate-950 p-3 rounded font-mono text-[11px] text-slate-300 overflow-x-auto border border-slate-800 leading-relaxed"></pre>
+              </details>
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB: MTR ================= -->
+          <div id="tools-view-mtr" class="hidden space-y-5">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
+              <form id="tools-mtr-form" onsubmit="executeMTR(event)" class="space-y-4">
+                <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
+                  <div class="md:col-span-2">
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Target Host or IP *</label>
+                    <input type="text" id="tools-mtr-target" required placeholder="e.g. 1.1.1.1 or 8.8.8.8" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Cycle Count</label>
+                    <select id="tools-mtr-count" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                      <option value="2">2 cycles (Fast)</option>
+                      <option value="3" selected>3 cycles</option>
+                      <option value="5">5 cycles</option>
+                      <option value="10">10 cycles (Deep)</option>
+                    </select>
+                  </div>
+                </div>
+
+                <div class="flex justify-end pt-2">
+                  <button type="submit" id="tools-mtr-btn" class="px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2">
+                    <span id="tools-mtr-spinner" class="hidden">↻</span>
+                    <span>Start MTR Report</span>
+                  </button>
+                </div>
+              </form>
+            </div>
+
+            <div id="tools-mtr-results" class="hidden space-y-4">
+              <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+                <div class="p-3 border-b border-slate-800 flex items-center justify-between">
+                  <span class="text-xs font-bold text-white">MTR Route Telemetry</span>
+                  <span id="tools-mtr-hops-count" class="text-xs font-mono text-slate-400">0 Hops</span>
+                </div>
+                <div class="overflow-x-auto">
+                  <table class="w-full text-left text-xs font-mono">
+                    <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                      <tr>
+                        <th class="py-2.5 px-3 w-16">Hop #</th>
+                        <th class="py-2.5 px-3">Host / Router</th>
+                        <th class="py-2.5 px-3">Loss %</th>
+                        <th class="py-2.5 px-3">Sent</th>
+                        <th class="py-2.5 px-3">Last (ms)</th>
+                        <th class="py-2.5 px-3">Avg (ms)</th>
+                        <th class="py-2.5 px-3">Best (ms)</th>
+                        <th class="py-2.5 px-3">Worst (ms)</th>
+                        <th class="py-2.5 px-3">StDev</th>
+                      </tr>
+                    </thead>
+                    <tbody id="tools-mtr-tbody" class="divide-y divide-slate-800/80"></tbody>
+                  </table>
+                </div>
+              </div>
+
+              <details class="bg-slate-900/60 border border-slate-800/80 rounded-xl p-3 text-xs">
+                <summary class="cursor-pointer text-slate-400 font-medium hover:text-white">Raw CLI Console Output</summary>
+                <pre id="tools-mtr-raw" class="mt-2 bg-slate-950 p-3 rounded font-mono text-[11px] text-slate-300 overflow-x-auto border border-slate-800 leading-relaxed"></pre>
+              </details>
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB: DNS LOOKUP ================= -->
+          <div id="tools-view-dns" class="hidden space-y-5">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
+              <form id="tools-dns-form" onsubmit="executeDNS(event)" class="space-y-4">
+                <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+                  <div class="md:col-span-2">
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Domain Name *</label>
+                    <input type="text" id="tools-dns-domain" required placeholder="e.g. cloudflare.com, google.com" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Record Type</label>
+                    <select id="tools-dns-type" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                      <option value="A" selected>A (IPv4)</option>
+                      <option value="AAAA">AAAA (IPv6)</option>
+                      <option value="CNAME">CNAME</option>
+                      <option value="MX">MX (Mail)</option>
+                      <option value="TXT">TXT</option>
+                      <option value="NS">NS (Nameserver)</option>
+                      <option value="SOA">SOA</option>
+                      <option value="ANY">ANY</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Custom DNS Server (Optional)</label>
+                    <input type="text" id="tools-dns-server" placeholder="e.g. 1.1.1.1, 8.8.8.8" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                </div>
+
+                <div class="flex justify-end pt-2">
+                  <button type="submit" id="tools-dns-btn" class="px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2">
+                    <span id="tools-dns-spinner" class="hidden">↻</span>
+                    <span>Resolve Domain</span>
+                  </button>
+                </div>
+              </form>
+            </div>
+
+            <div id="tools-dns-results" class="hidden space-y-4">
+              <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+                <div class="p-3 border-b border-slate-800 flex items-center justify-between">
+                  <div class="flex items-center gap-2">
+                    <span class="text-xs font-bold text-white">DNS Answer Records</span>
+                    <span id="tools-dns-meta" class="text-[11px] font-mono text-cyan-300"></span>
+                  </div>
+                </div>
+                <div class="overflow-x-auto">
+                  <table class="w-full text-left text-xs font-mono">
+                    <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                      <tr>
+                        <th class="py-2.5 px-3">Name</th>
+                        <th class="py-2.5 px-3">TTL (s)</th>
+                        <th class="py-2.5 px-3">Class</th>
+                        <th class="py-2.5 px-3">Type</th>
+                        <th class="py-2.5 px-3">Value / Target</th>
+                      </tr>
+                    </thead>
+                    <tbody id="tools-dns-tbody" class="divide-y divide-slate-800/80"></tbody>
+                  </table>
+                </div>
+              </div>
+
+              <details class="bg-slate-900/60 border border-slate-800/80 rounded-xl p-3 text-xs">
+                <summary class="cursor-pointer text-slate-400 font-medium hover:text-white">Raw CLI Console Output</summary>
+                <pre id="tools-dns-raw" class="mt-2 bg-slate-950 p-3 rounded font-mono text-[11px] text-slate-300 overflow-x-auto border border-slate-800 leading-relaxed"></pre>
+              </details>
+            </div>
+          </div>
+
+          <!-- ================= SUBTAB: PORT PROBE ================= -->
+          <div id="tools-view-port" class="hidden space-y-5">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
+              <form id="tools-port-form" onsubmit="executePortProbe(event)" class="space-y-4">
+                <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+                  <div class="md:col-span-2">
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Target Host or IP *</label>
+                    <input type="text" id="tools-port-target" required placeholder="e.g. 1.1.1.1, 192.168.131.1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Port (TCP) *</label>
+                    <input type="number" id="tools-port-num" required min="1" max="65535" value="80" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Timeout (s)</label>
+                    <input type="number" id="tools-port-timeout" value="3" min="1" max="10" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                  </div>
+                </div>
+
+                <!-- Quick Presets -->
+                <div class="flex flex-wrap items-center gap-2 pt-1">
+                  <span class="text-[11px] text-slate-400">Quick Ports:</span>
+                  <button type="button" onclick="setPortPreset(80)" class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono text-cyan-300 border border-slate-700">HTTP: 80</button>
+                  <button type="button" onclick="setPortPreset(443)" class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono text-emerald-300 border border-slate-700">HTTPS: 443</button>
+                  <button type="button" onclick="setPortPreset(22)" class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono text-purple-300 border border-slate-700">SSH: 22</button>
+                  <button type="button" onclick="setPortPreset(53)" class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono text-indigo-300 border border-slate-700">DNS: 53</button>
+                  <button type="button" onclick="setPortPreset(179)" class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono text-amber-300 border border-slate-700">BGP: 179</button>
+                  <button type="button" onclick="setPortPreset(8291)" class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono text-pink-300 border border-slate-700">Winbox: 8291</button>
+                  <button type="button" onclick="setPortPreset(8006)" class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono text-orange-300 border border-slate-700">PVE: 8006</button>
+                </div>
+
+                <div class="flex justify-end pt-2">
+                  <button type="submit" id="tools-port-btn" class="px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2">
+                    <span id="tools-port-spinner" class="hidden">↻</span>
+                    <span>Probe Port</span>
+                  </button>
+                </div>
+              </form>
+            </div>
+
+            <div id="tools-port-results" class="hidden space-y-4">
+              <div id="tools-port-banner" class="rounded-xl p-5 border flex items-center justify-between">
+                <div class="flex items-center gap-3">
+                  <span id="tools-port-icon" class="w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm"></span>
+                  <div>
+                    <h3 id="tools-port-title" class="text-sm font-bold"></h3>
+                    <p id="tools-port-sub" class="text-xs mt-0.5 font-mono"></p>
+                  </div>
+                </div>
+                <div class="text-right">
+                  <div class="text-[10px] uppercase text-slate-400">Handshake Latency</div>
+                  <div id="tools-port-latency" class="text-base font-bold font-mono text-white"></div>
+                </div>
+              </div>
+
+              <details class="bg-slate-900/60 border border-slate-800/80 rounded-xl p-3 text-xs">
+                <summary class="cursor-pointer text-slate-400 font-medium hover:text-white">Handshake Details</summary>
+                <pre id="tools-port-raw" class="mt-2 bg-slate-950 p-3 rounded font-mono text-[11px] text-slate-300 overflow-x-auto border border-slate-800 leading-relaxed"></pre>
+              </details>
+            </div>
+          </div>
+        </section>
+
       </main>
     </div>
   </div>
@@ -4552,6 +4960,6 @@
     </div>
   </div>
 
-  <script src="app.js?v=3.6"></script>
+  <script src="app.js?v=3.7"></script>
 </body>
 </html>