Files

610 lines
22 KiB
Go

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"
}