package tools import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" ) // VoIP constants const ( SIPUDPPort = 5060 SIPTCPPort = 5060 SIPTLSPort = 5061 RTPPortRange = "50000-60000" DefaultPCAPLimit = 5 * 1024 * 1024 ) func registerVoIPTools(s *mcp.Server) { // voip_discover_containers s.AddTool(&mcp.Tool{ Name: "voip_discover_containers", Description: "Find VoIP-related containers by name/image keywords", InputSchema: schema(map[string]any{ "keywords": arrayStringProps("Keywords to match (default: gw, media, fs, sbc, sw)", false), "target": strProps("Connection alias (default: primary)", false), }, nil), }, voipDiscoverHandler) // voip_sip_capture s.AddTool(&mcp.Tool{ Name: "voip_sip_capture", Description: "Capture SIP signaling to PCAP using sngrep inside container", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "duration": intProps("Capture duration in seconds (default: 30)", false), "port": intProps("SIP port to filter (default: 5060)", false), "protocol": strProps("Protocol filter (default: all)", false, "udp", "tcp", "tls"), "target": strProps("Connection alias (default: primary)", false), }, []string{"container"}), }, sipCaptureHandler) // voip_call_flow s.AddTool(&mcp.Tool{ Name: "voip_call_flow", Description: "Parse SIP call flow from a PCAP file", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "pcap_file": strProps("Path to PCAP file in container", true), "call_id": strProps("Filter by Call-ID", false), "phone_number": strProps("Filter by phone number", false), "summary_only": boolProps("Return summary only, no message details", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"container", "pcap_file"}), }, callFlowHandler) // voip_registrations s.AddTool(&mcp.Tool{ Name: "voip_registrations", Description: "Extract REGISTER dialogs and outcomes from SIP PCAP", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "pcap_file": strProps("Path to PCAP file", true), "target": strProps("Connection alias (default: primary)", false), }, []string{"container", "pcap_file"}), }, registrationsHandler) // voip_call_stats s.AddTool(&mcp.Tool{ Name: "voip_call_stats", Description: "Aggregate SIP call statistics from PCAP", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "pcap_file": strProps("Path to PCAP file", true), "target": strProps("Connection alias (default: primary)", false), }, []string{"container", "pcap_file"}), }, callStatsHandler) // voip_extract_sdp s.AddTool(&mcp.Tool{ Name: "voip_extract_sdp", Description: "Extract SDP (codecs, RTP ports) from SIP messages", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "pcap_file": strProps("Path to PCAP file", true), "call_id": strProps("Filter by specific Call-ID", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"container", "pcap_file"}), }, extractSDPHandler) // voip_packet_check s.AddTool(&mcp.Tool{ Name: "voip_packet_check", Description: "Quick SIP packet presence check on standard ports", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "duration": intProps("Check duration in seconds (default: 5)", false), "interface": strProps("Network interface (default: any)", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"container"}), }, packetCheckHandler) // voip_network_capture s.AddTool(&mcp.Tool{ Name: "voip_network_capture", Description: "Capture SIP packets with tcpdump for analysis", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "duration": intProps("Capture duration in seconds (default: 30)", false), "interface": strProps("Network interface (default: any)", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"container"}), }, networkCaptureHandler) // voip_rtp_capture s.AddTool(&mcp.Tool{ Name: "voip_rtp_capture", Description: "Capture RTP packets to verify media flow", InputSchema: schema(map[string]any{ "container": strProps("Container name", true), "duration": intProps("Capture duration in seconds (default: 10)", false), "port_range": strProps("RTP port range (default: 50000-60000)", false), "interface": strProps("Network interface (default: any)", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"container"}), }, rtpCaptureHandler) // voip_network_diagnostics s.AddTool(&mcp.Tool{ Name: "voip_network_diagnostics", Description: "Run network diagnostics: ping, traceroute, TCP port checks", InputSchema: schema(map[string]any{ "host": strProps("Target host for diagnostics", true), "ports": arrayNumberProps("TCP ports to check (default: 5060, 5061)", false), "ping_count": intProps("Number of pings (default: 3)", false), "traceroute": boolProps("Include traceroute (default: true)", false), "timeout": intProps("Timeout in seconds (default: 15)", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"host"}), }, networkDiagnosticsHandler) } func voipDiscoverHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) target := getString(args, "target", "primary") if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } // Default VoIP keywords defaultKeywords := []string{"gw", "media", "fs", "sbc", "sw", "freeswitch", "asterisk", "kamailio", "opensips", "rtpengine"} keywords := defaultKeywords if kws, ok := getStringArray(args, "keywords"); ok { var sanitized []string for _, kw := range kws { if safe := sanitizeAlphanumeric(kw); safe != "" { sanitized = append(sanitized, safe) } } if len(sanitized) > 0 { keywords = sanitized } } pattern := strings.Join(keywords, "|") cmd := fmt.Sprintf(`docker ps --format '{{.Names}}|{{.Image}}' | grep -iE %s 2>/dev/null || echo ''`, shellQuote(pattern)) output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } if trimOutput(output) == "" { return textResult("No VoIP containers found"), nil } var containers []map[string]string for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.SplitN(line, "|", 2) if len(parts) == 2 { containers = append(containers, map[string]string{ "name": parts[0], "image": parts[1], }) } } jsonBytes, _ := json.MarshalIndent(containers, "", " ") return textResult(string(jsonBytes)), nil } func sipCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") duration := getInt(args, "duration", 30) port := getInt(args, "port", 0) protocol := getString(args, "protocol", "") target := getString(args, "target", "primary") if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } checkCmd := fmt.Sprintf("docker exec %s command -v sngrep >/dev/null 2>&1 && echo 'ok' || echo 'missing'", shellQuote(container)) checkOutput, err := m.Execute(ctx, checkCmd, target) if err != nil || !containsString(checkOutput, "ok") { return errorResult("sngrep not available in container. Install with: apt-get install sngrep"), nil } bpfFilter := buildSIPFilter(port, protocol) pcapPath := fmt.Sprintf("/tmp/voip_sip_%d.pcap", time.Now().Unix()) cmd := fmt.Sprintf("docker exec %s timeout %ds sngrep -N -q -d any -O %s '%s' 2>&1 || true", shellQuote(container), duration, shellQuote(pcapPath), bpfFilter) m.Execute(ctx, cmd, target) checkFile := fmt.Sprintf("docker exec %s test -f %s && echo 'exists' || echo 'missing'", shellQuote(container), shellQuote(pcapPath)) checkResult, _ := m.Execute(ctx, checkFile, target) fileStatus := "created" if !containsString(checkResult, "exists") { fileStatus = "not created (capture may have failed)" } result := map[string]any{ "container": container, "pcap_file": pcapPath, "duration": duration, "filter": bpfFilter, "file_status": fileStatus, "message": fmt.Sprintf("SIP capture completed. Use voip_call_flow to analyze %s", pcapPath), } jsonBytes, _ := json.MarshalIndent(result, "", " ") return textResult(string(jsonBytes)), nil } func callFlowHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") rawPcapFile, _ := requireString(args, "pcap_file") callID := getString(args, "call_id", "") phoneNumber := getString(args, "phone_number", "") summaryOnly := getBool(args, "summary_only", false) target := getString(args, "target", "primary") pcapFile, err := sanitizeShellInnerPath(rawPcapFile) if err != nil { return errorResult("invalid pcap_file path"), nil } if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } var filter string if callID != "" { filter = fmt.Sprintf("-Y 'sip.Call-ID == \"%s\"'", sanitizeTsharkValue(callID)) } else if phoneNumber != "" { filter = fmt.Sprintf("-Y 'sip contains \"%s\"'", sanitizeTsharkValue(phoneNumber)) } quotedPcap := shellQuote(pcapFile) var cmd string if summaryOnly { cmd = fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -T fields -e frame.time -e ip.src -e ip.dst -e sip.Method -e sip.Status-Code -e sip.Call-ID %s 2>/dev/null | head -100; else sngrep -I %s -q 2>/dev/null | head -50 || echo "No analysis tool available"; fi'`, shellQuote(container), quotedPcap, filter, quotedPcap) } else { cmd = fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -V -Y sip %s 2>/dev/null | head -500; else cat %s 2>/dev/null | strings | grep -E "^(INVITE|REGISTER|BYE|ACK|CANCEL|SIP/2.0)" | head -100 || echo "No analysis tool available"; fi'`, shellQuote(container), quotedPcap, filter, quotedPcap) } output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } return textResult(output), nil } func registrationsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") rawPcapFile, _ := requireString(args, "pcap_file") target := getString(args, "target", "primary") pcapFile, err := sanitizeShellInnerPath(rawPcapFile) if err != nil { return errorResult("invalid pcap_file path"), nil } if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } quotedPcap := shellQuote(pcapFile) cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -Y "sip.Method == REGISTER or (sip.CSeq.method == REGISTER and sip.Status-Code)" -T fields -e frame.time -e sip.from.user -e sip.to.user -e sip.contact.uri -e sip.Status-Code -E header=y 2>/dev/null; else cat %s 2>/dev/null | strings | grep -E "(REGISTER|200 OK|401|403)" | head -50; fi'`, shellQuote(container), quotedPcap, quotedPcap) output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } return textResult(output), nil } func callStatsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") rawPcapFile, _ := requireString(args, "pcap_file") target := getString(args, "target", "primary") pcapFile, err := sanitizeShellInnerPath(rawPcapFile) if err != nil { return errorResult("invalid pcap_file path"), nil } if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } quotedPcap := shellQuote(pcapFile) cmd := fmt.Sprintf(`docker exec %s sh -c ' if command -v tshark >/dev/null 2>&1; then echo "=== SIP STATISTICS ===" echo "" echo "--- Request Methods ---" tshark -r %s -Y sip.Method -T fields -e sip.Method 2>/dev/null | sort | uniq -c | sort -rn echo "" echo "--- Response Codes ---" tshark -r %s -Y sip.Status-Code -T fields -e sip.Status-Code 2>/dev/null | sort | uniq -c | sort -rn echo "" echo "--- Unique Call-IDs ---" tshark -r %s -Y sip -T fields -e sip.Call-ID 2>/dev/null | sort -u | wc -l | xargs echo "Total calls:" else cat %s 2>/dev/null | strings | grep -oE "^(INVITE|REGISTER|BYE|ACK|CANCEL|OPTIONS|SIP/2.0 [0-9]+)" | sort | uniq -c | sort -rn fi'`, shellQuote(container), quotedPcap, quotedPcap, quotedPcap, quotedPcap) output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } return textResult(output), nil } func extractSDPHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") rawPcapFile, _ := requireString(args, "pcap_file") callID := getString(args, "call_id", "") target := getString(args, "target", "primary") pcapFile, err := sanitizeShellInnerPath(rawPcapFile) if err != nil { return errorResult("invalid pcap_file path"), nil } if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } var filter string if callID != "" { filter = fmt.Sprintf("-Y 'sip.Call-ID == \"%s\" and sdp'", sanitizeTsharkValue(callID)) } else { filter = "-Y 'sdp'" } quotedPcap := shellQuote(pcapFile) cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s %s -T fields -e sdp.connection_info -e sdp.media -e sdp.media.port -e sdp.media.format -E header=y 2>/dev/null | head -50; else cat %s 2>/dev/null | strings | grep -E "^(c=|m=|a=rtpmap)" | head -50; fi'`, shellQuote(container), quotedPcap, filter, quotedPcap) output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } return textResult(output), nil } func packetCheckHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") duration := getInt(args, "duration", 5) iface := sanitizeAlphanumeric(getString(args, "interface", "any")) target := getString(args, "target", "primary") if iface == "" { iface = "any" } if duration < 1 || duration > 300 { duration = 5 } if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -c 20 port 5060 or port 5061 2>&1 | tail -25; else echo "tcpdump not available"; fi'`, shellQuote(container), duration, shellQuote(iface)) output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } hasPackets := containsString(output, "UDP") || containsString(output, "TCP") || containsString(output, "SIP") summary := "SIP packets detected: NO" if hasPackets { summary = "SIP packets detected: YES" } return textResult(fmt.Sprintf("%s\n\n%s", summary, output)), nil } func networkCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") duration := getInt(args, "duration", 30) iface := sanitizeAlphanumeric(getString(args, "interface", "any")) target := getString(args, "target", "primary") if iface == "" { iface = "any" } if duration < 1 || duration > 300 { duration = 30 } if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } pcapPath := fmt.Sprintf("/tmp/voip_net_%d.pcap", time.Now().Unix()) cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -w %s port 5060 or port 5061 2>&1 || true; else echo "tcpdump not available"; fi'`, shellQuote(container), duration, shellQuote(iface), shellQuote(pcapPath)) m.Execute(ctx, cmd, target) checkFile := fmt.Sprintf("docker exec %s test -f %s && echo 'exists' || echo 'missing'", shellQuote(container), shellQuote(pcapPath)) checkResult, _ := m.Execute(ctx, checkFile, target) fileStatus := "created" if !containsString(checkResult, "exists") { fileStatus = "not created (capture may have failed)" } result := map[string]any{ "container": container, "pcap_file": pcapPath, "duration": duration, "interface": iface, "file_status": fileStatus, "message": "Network capture complete. Analyze with voip_call_flow or copy with docker_cp_from", } jsonBytes, _ := json.MarshalIndent(result, "", " ") return textResult(string(jsonBytes)), nil } func rtpCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) container, _ := requireString(args, "container") duration := getInt(args, "duration", 10) portRange := getString(args, "port_range", RTPPortRange) iface := sanitizeAlphanumeric(getString(args, "interface", "any")) target := getString(args, "target", "primary") if iface == "" { iface = "any" } if duration < 1 || duration > 300 { duration = 10 } if err := checkDockerAvailable(ctx, m, target); err != nil { return errorResult(err.Error()), nil } startPort := 50000 endPort := 60000 ports := strings.Split(portRange, "-") if len(ports) == 2 { if sp, err := strconv.Atoi(strings.TrimSpace(ports[0])); err == nil && sp > 0 && sp <= 65535 { startPort = sp } if ep, err := strconv.Atoi(strings.TrimSpace(ports[1])); err == nil && ep > 0 && ep <= 65535 { endPort = ep } } cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -c 100 "udp portrange %d-%d" 2>&1 | tail -20; else echo "tcpdump not available"; fi'`, shellQuote(container), duration, shellQuote(iface), startPort, endPort) output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } hasRTP := containsString(output, "UDP") && containsString(output, "packets") summary := "RTP packets detected: NO" if hasRTP { summary = "RTP packets detected: YES" } return textResult(fmt.Sprintf("%s\nPort range: %s\n\n%s", summary, portRange, output)), nil } func networkDiagnosticsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) host, _ := requireString(args, "host") pingCount := getInt(args, "ping_count", 3) doTraceroute := getBool(args, "traceroute", true) timeout := getInt(args, "timeout", 15) target := getString(args, "target", "primary") ports := []int{5060, 5061} if ps, ok := getIntArray(args, "ports"); ok { ports = ps } var sb strings.Builder fmt.Fprintf(&sb, "=== NETWORK DIAGNOSTICS: %s ===\n\n", host) sb.WriteString("--- PING ---\n") pingCmd := fmt.Sprintf("ping -c %d -W 3 %s 2>&1 || echo 'Ping failed'", pingCount, shellQuote(host)) pingOutput, _ := m.Execute(ctx, pingCmd, target) sb.WriteString(pingOutput) sb.WriteString("\n\n") if doTraceroute { sb.WriteString("--- TRACEROUTE ---\n") traceCmd := fmt.Sprintf("timeout %ds traceroute -m 15 %s 2>&1 || tracepath %s 2>&1 || echo 'Traceroute not available'", timeout, shellQuote(host), shellQuote(host)) traceOutput, _ := m.Execute(ctx, traceCmd, target) sb.WriteString(traceOutput) sb.WriteString("\n\n") } sb.WriteString("--- TCP PORT CHECKS ---\n") for _, port := range ports { checkCmd := fmt.Sprintf("timeout 3 bash -c 'echo >/dev/tcp/%s/%d' 2>&1 && echo 'Port %d: OPEN' || echo 'Port %d: CLOSED/FILTERED'", shellQuote(host), port, port, port) checkOutput, _ := m.Execute(ctx, checkCmd, target) fmt.Fprintf(&sb, "%s\n", trimOutput(checkOutput)) } return textResult(sb.String()), nil } // buildSIPFilter создаёт BPF-фильтр для SIP-трафика. func buildSIPFilter(port int, protocol string) string { if protocol != "" { proto := strings.ToLower(protocol) switch proto { case "tls": if port == 0 { port = SIPTLSPort } return fmt.Sprintf("tcp port %d", port) case "tcp": if port == 0 { port = SIPTCPPort } return fmt.Sprintf("tcp port %d", port) case "udp": if port == 0 { port = SIPUDPPort } return fmt.Sprintf("udp port %d", port) } } if port != 0 { return fmt.Sprintf("udp port %d or tcp port %d", port, port) } return "udp port 5060 or tcp port 5060 or tcp port 5061" }