All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 7s
483 lines
15 KiB
Python
483 lines
15 KiB
Python
# src/sniffer.py
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
import time
|
|
import socket
|
|
import selectors
|
|
import errno
|
|
import struct
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
# NOTE: ensure this path points to your shared runtime module
|
|
from src import shared_objects
|
|
|
|
from scapy.all import (
|
|
Ether,
|
|
ARP,
|
|
IP,
|
|
IPv6,
|
|
TCP,
|
|
UDP,
|
|
ICMP,
|
|
ICMPv6Unknown,
|
|
Dot1Q,
|
|
Raw,
|
|
)
|
|
|
|
from src.utilities.interface_bridge_helpers import (
|
|
check_interface_exists,
|
|
check_interface_up,
|
|
get_bridge_ports_once,
|
|
)
|
|
from src.Models.etherType import EtherTypeEnum, ethertype_from_int
|
|
from src.Models.ip_protocol import IPProtocolEnum, protocol_from_number
|
|
|
|
# ---- Logging ----------------------------------------------------------
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger("af_packet_sniffer")
|
|
|
|
# ---- Globals (kept minimal) -------------------------------------------
|
|
af_sockets: Dict[str, socket.socket] = {}
|
|
af_thread: Optional[threading.Thread] = None
|
|
af_stop_event: Optional[threading.Event] = None
|
|
|
|
fixed_bridge_ports: Dict[str, List[str]] = {}
|
|
current_bridge: Optional[str] = None
|
|
|
|
# small bounded buffer for packets produced before shared_objects is ready
|
|
_PACKET_BUFFER: List[Dict[str, Any]] = []
|
|
_BUFFER_CAPACITY = 20000
|
|
|
|
# Background asyncio loop used for internal tasks in this module (kept but not used for DB pool)
|
|
async_loop = asyncio.new_event_loop()
|
|
|
|
|
|
def _start_async_loop(loop: asyncio.AbstractEventLoop) -> None:
|
|
asyncio.set_event_loop(loop)
|
|
loop.run_forever()
|
|
|
|
|
|
# Start background loop in daemon thread immediately
|
|
threading.Thread(target=_start_async_loop, args=(async_loop,), daemon=True).start()
|
|
|
|
|
|
# -------------------------
|
|
# Helpers for buffer draining
|
|
# -------------------------
|
|
def drain_buffer_to_shared_db() -> None:
|
|
"""
|
|
Attempt to schedule buffered packets for insertion on shared_objects.web_loop.
|
|
Call this from main.py after shared_objects.db and shared_objects.web_loop are initialized.
|
|
"""
|
|
try:
|
|
web_loop = getattr(shared_objects, "web_loop", None)
|
|
web_db = getattr(shared_objects, "db", None)
|
|
if web_db is None or web_loop is None:
|
|
return
|
|
|
|
# schedule draining on the web loop to avoid blocking this thread
|
|
def _drain() -> None:
|
|
while _PACKET_BUFFER:
|
|
pkt = _PACKET_BUFFER.pop(0)
|
|
try:
|
|
asyncio.run_coroutine_threadsafe(web_db.insert_packet(pkt), web_loop)
|
|
except Exception:
|
|
# re-buffer first element and stop to avoid busy loop
|
|
_PACKET_BUFFER.insert(0, pkt)
|
|
break
|
|
|
|
try:
|
|
web_loop.call_soon_threadsafe(_drain)
|
|
except Exception:
|
|
# fallback: run directly (best-effort)
|
|
_drain()
|
|
except Exception:
|
|
logger.exception("Failed to drain packet buffer")
|
|
|
|
|
|
# -------------------------
|
|
# Packet parsing
|
|
# -------------------------
|
|
def _safe_get_attr(layer, attr: str):
|
|
try:
|
|
return getattr(layer, attr, None)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def parse_packet(pkt, bridge: str) -> None:
|
|
"""
|
|
Parse a scapy Packet object into a normalized dict and schedule DB insert.
|
|
"""
|
|
pkt_iface = getattr(pkt, "sniffed_on", None)
|
|
if not pkt_iface:
|
|
return # can't determine interface context
|
|
|
|
logger.debug("Packet captured on %s (bridge %s)", pkt_iface, bridge)
|
|
|
|
pkt_info: Dict[str, Any] = {
|
|
"iface": pkt_iface,
|
|
"length": len(pkt),
|
|
"raw": bytes(pkt),
|
|
"src_mac": None,
|
|
"dst_mac": None,
|
|
"eth_type_raw": None,
|
|
"eth_type": EtherTypeEnum.UNKNOWN,
|
|
"vlan_id": None,
|
|
"protocol_raw": None,
|
|
"protocol": IPProtocolEnum.UNKNOWN,
|
|
"protocol_name": None,
|
|
"src_ip": None,
|
|
"dst_ip": None,
|
|
"src_port": None,
|
|
"dst_port": None,
|
|
}
|
|
|
|
# Ethernet layer
|
|
if Ether in pkt:
|
|
eth = pkt[Ether]
|
|
pkt_info["src_mac"] = _safe_get_attr(eth, "src")
|
|
pkt_info["dst_mac"] = _safe_get_attr(eth, "dst")
|
|
|
|
# Base ethertype
|
|
try:
|
|
eth_type_raw = int(eth.type)
|
|
except Exception:
|
|
eth_type_raw = None
|
|
|
|
# VLAN inner ethertype and vlan id if Dot1Q exists
|
|
if Dot1Q in pkt:
|
|
try:
|
|
inner = int(pkt[Dot1Q].type)
|
|
if inner:
|
|
eth_type_raw = inner
|
|
except Exception:
|
|
pass
|
|
try:
|
|
pkt_info["vlan_id"] = int(pkt[Dot1Q].vlan)
|
|
except Exception:
|
|
pkt_info["vlan_id"] = None
|
|
|
|
if eth_type_raw is not None:
|
|
pkt_info["eth_type_raw"] = eth_type_raw
|
|
try:
|
|
pkt_info["eth_type"] = ethertype_from_int(eth_type_raw)
|
|
except Exception:
|
|
pkt_info["eth_type"] = EtherTypeEnum.UNKNOWN
|
|
|
|
# ARP
|
|
if ARP in pkt:
|
|
arp = pkt[ARP]
|
|
pkt_info["protocol_name"] = "ARP"
|
|
pkt_info["src_ip"] = _safe_get_attr(arp, "psrc")
|
|
pkt_info["dst_ip"] = _safe_get_attr(arp, "pdst")
|
|
pkt_info["src_port"] = None
|
|
pkt_info["dst_port"] = None
|
|
|
|
# IPv4
|
|
if IP in pkt:
|
|
ip = pkt[IP]
|
|
pkt_info["src_ip"] = _safe_get_attr(ip, "src")
|
|
pkt_info["dst_ip"] = _safe_get_attr(ip, "dst")
|
|
|
|
try:
|
|
proto_num = int(_safe_get_attr(ip, "proto"))
|
|
except Exception:
|
|
proto_num = None
|
|
|
|
if proto_num is not None:
|
|
pkt_info["protocol_raw"] = proto_num
|
|
try:
|
|
pkt_info["protocol"] = protocol_from_number(proto_num)
|
|
except Exception:
|
|
pkt_info["protocol"] = IPProtocolEnum.UNKNOWN
|
|
|
|
if proto_num == 6 and TCP in pkt:
|
|
pkt_info["protocol_name"] = "TCP"
|
|
pkt_info["src_port"] = _safe_get_attr(pkt[TCP], "sport")
|
|
pkt_info["dst_port"] = _safe_get_attr(pkt[TCP], "dport")
|
|
elif proto_num == 17 and UDP in pkt:
|
|
pkt_info["protocol_name"] = "UDP"
|
|
pkt_info["src_port"] = _safe_get_attr(pkt[UDP], "sport")
|
|
pkt_info["dst_port"] = _safe_get_attr(pkt[UDP], "dport")
|
|
elif proto_num == 1 and ICMP in pkt:
|
|
pkt_info["protocol_name"] = "ICMP"
|
|
else:
|
|
if pkt_info["protocol_name"] is None:
|
|
pkt_info["protocol_name"] = f"IP_PROTO_{proto_num}" if proto_num is not None else None
|
|
|
|
# IPv6
|
|
if IPv6 in pkt:
|
|
ip6 = pkt[IPv6]
|
|
pkt_info["src_ip"] = _safe_get_attr(ip6, "src")
|
|
pkt_info["dst_ip"] = _safe_get_attr(ip6, "dst")
|
|
|
|
try:
|
|
nh = int(_safe_get_attr(ip6, "nh"))
|
|
except Exception:
|
|
nh = None
|
|
|
|
if nh is not None:
|
|
pkt_info["protocol_raw"] = nh
|
|
try:
|
|
pkt_info["protocol"] = protocol_from_number(nh)
|
|
except Exception:
|
|
pkt_info["protocol"] = IPProtocolEnum.UNKNOWN
|
|
|
|
if nh == 6 and TCP in pkt:
|
|
pkt_info["protocol_name"] = "TCP"
|
|
pkt_info["src_port"] = _safe_get_attr(pkt[TCP], "sport")
|
|
pkt_info["dst_port"] = _safe_get_attr(pkt[TCP], "dport")
|
|
elif nh == 17 and UDP in pkt:
|
|
pkt_info["protocol_name"] = "UDP"
|
|
pkt_info["src_port"] = _safe_get_attr(pkt[UDP], "sport")
|
|
pkt_info["dst_port"] = _safe_get_attr(pkt[UDP], "dport")
|
|
elif ICMPv6Unknown in pkt:
|
|
pkt_info["protocol_name"] = "ICMPv6"
|
|
else:
|
|
if pkt_info["protocol_name"] is None:
|
|
pkt_info["protocol_name"] = f"IPV6_PROTO_{nh}" if nh is not None else None
|
|
|
|
# Raw fallback label
|
|
if Raw in pkt and not pkt_info["protocol_name"]:
|
|
pkt_info["protocol_name"] = "RAW"
|
|
|
|
# Submit DB insert to shared web loop if available, otherwise buffer
|
|
try:
|
|
web_loop = getattr(shared_objects, "web_loop", None)
|
|
web_db = getattr(shared_objects, "db", None)
|
|
if web_db is not None and web_loop is not None:
|
|
asyncio.run_coroutine_threadsafe(web_db.insert_packet(pkt_info), web_loop)
|
|
else:
|
|
# buffer (bounded) until the web app initializes
|
|
_PACKET_BUFFER.append(pkt_info)
|
|
if len(_PACKET_BUFFER) > _BUFFER_CAPACITY:
|
|
# drop oldest packet
|
|
_PACKET_BUFFER.pop(0)
|
|
except Exception:
|
|
logger.exception("Failed to schedule DB insert")
|
|
|
|
|
|
# -------------------------
|
|
# AF_PACKET socket utilities
|
|
# -------------------------
|
|
def _create_af_packet_socket(ifname: str, rx_buf: int = 4 * 1024 * 1024) -> Optional[socket.socket]:
|
|
"""
|
|
Create and bind an AF_PACKET raw socket to interface.
|
|
Non-blocking socket returned or None on failure.
|
|
"""
|
|
try:
|
|
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003)) # ETH_P_ALL
|
|
except PermissionError:
|
|
logger.exception("Permission denied creating AF_PACKET socket for %s", ifname)
|
|
return None
|
|
except Exception:
|
|
logger.exception("Failed creating AF_PACKET socket for %s", ifname)
|
|
return None
|
|
|
|
try:
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rx_buf)
|
|
except Exception:
|
|
logger.debug("SO_RCVBUF set failed for %s (non-fatal)", ifname)
|
|
|
|
# Try TPACKET_V3 best-effort
|
|
try:
|
|
SOL_PACKET = getattr(socket, "SOL_PACKET", 263)
|
|
PACKET_VERSION = getattr(socket, "PACKET_VERSION", 10)
|
|
TPACKET_V3 = 3
|
|
s.setsockopt(SOL_PACKET, PACKET_VERSION, struct.pack("I", TPACKET_V3))
|
|
logger.debug("Requested TPACKET_V3 on %s", ifname)
|
|
except Exception:
|
|
logger.debug("TPACKETv3 not available for %s", ifname)
|
|
|
|
try:
|
|
s.bind((ifname, 0))
|
|
except OSError as e:
|
|
logger.exception("Bind failed for %s: %s", ifname, e)
|
|
s.close()
|
|
return None
|
|
|
|
s.setblocking(False)
|
|
return s
|
|
|
|
|
|
def _close_socket(ifname: str) -> None:
|
|
s = af_sockets.pop(ifname, None)
|
|
if s:
|
|
try:
|
|
s.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# -------------------------
|
|
# Main reader thread
|
|
# -------------------------
|
|
def afpacket_reader_loop(bridge: str, stop_event: threading.Event) -> None:
|
|
"""
|
|
Multiplex AF_PACKET sockets using a selector and hand packets to parse_packet.
|
|
Uses the fixed_bridge_ports snapshot to decide which interfaces to open.
|
|
"""
|
|
logger.info("AF_PACKET reader starting for bridge %s", bridge)
|
|
sel = selectors.DefaultSelector()
|
|
|
|
# create sockets for fixed snapshot (if any)
|
|
for iface in fixed_bridge_ports.get(bridge, []):
|
|
if iface in af_sockets:
|
|
continue
|
|
if not check_interface_exists(iface):
|
|
logger.warning("Snapshot port %s missing, skipping", iface)
|
|
continue
|
|
s = _create_af_packet_socket(iface)
|
|
if s:
|
|
af_sockets[iface] = s
|
|
|
|
# register existing sockets
|
|
for iface, s in list(af_sockets.items()):
|
|
try:
|
|
sel.register(s, selectors.EVENT_READ, data=iface)
|
|
except Exception:
|
|
logger.debug("Register failed for %s (continuing)", iface)
|
|
|
|
try:
|
|
while not stop_event.is_set():
|
|
# ensure newly created sockets are registered
|
|
for iface, s in list(af_sockets.items()):
|
|
try:
|
|
if not any(k.fileobj is s for k in sel.get_map().values()):
|
|
sel.register(s, selectors.EVENT_READ, data=iface)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
events = sel.select(timeout=1.0)
|
|
except Exception:
|
|
logger.exception("Selector error")
|
|
time.sleep(0.1)
|
|
continue
|
|
|
|
if not events:
|
|
continue
|
|
|
|
for key, _ in events:
|
|
sock: socket.socket = key.fileobj
|
|
iface: str = key.data
|
|
try:
|
|
raw = sock.recv(65536)
|
|
if not raw:
|
|
continue
|
|
except BlockingIOError:
|
|
continue
|
|
except OSError as e:
|
|
if e.errno in (errno.ENODEV, errno.ENETDOWN, errno.EBADF):
|
|
logger.warning("Socket error on %s: %s — closing", iface, e)
|
|
try:
|
|
sel.unregister(sock)
|
|
except Exception:
|
|
pass
|
|
_close_socket(iface)
|
|
continue
|
|
else:
|
|
logger.exception("Recv error on %s", iface)
|
|
continue
|
|
|
|
# parse with scapy
|
|
try:
|
|
pkt = Ether(raw)
|
|
pkt.sniffed_on = iface
|
|
parse_packet(pkt, bridge)
|
|
except Exception:
|
|
logger.exception("Failed to parse/process packet from %s", iface)
|
|
continue
|
|
finally:
|
|
logger.info("AF_PACKET reader stopping; cleaning up sockets")
|
|
# unregister and close
|
|
try:
|
|
for key in list(sel.get_map().values()):
|
|
try:
|
|
sel.unregister(key.fileobj)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
for iface in list(af_sockets.keys()):
|
|
_close_socket(iface)
|
|
|
|
try:
|
|
sel.close()
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info("AF_PACKET reader stopped")
|
|
|
|
|
|
# -------------------------
|
|
# Public API: start/stop/status
|
|
# -------------------------
|
|
def start_afpacket_sniffer(bridge: str) -> None:
|
|
"""
|
|
Start the sniffer: snapshot ports once, spin up reader thread.
|
|
"""
|
|
global af_thread, af_stop_event, current_bridge
|
|
if af_thread and af_thread.is_alive():
|
|
logger.info("Sniffer already running")
|
|
return
|
|
|
|
ports = get_bridge_ports_once(bridge)
|
|
fixed_bridge_ports[bridge] = ports
|
|
current_bridge = bridge
|
|
logger.info("Fixed bridge ports for %s: %s", bridge, ports)
|
|
|
|
af_stop_event = threading.Event()
|
|
af_thread = threading.Thread(target=afpacket_reader_loop, args=(bridge, af_stop_event), daemon=True)
|
|
af_thread.start()
|
|
logger.info("AF_PACKET sniffer started")
|
|
|
|
|
|
def stop_afpacket_sniffer() -> None:
|
|
"""
|
|
Stop the reader thread and clear snapshot. Also attempt to close DB pool.
|
|
"""
|
|
global af_thread, af_stop_event, current_bridge
|
|
if not af_thread:
|
|
return
|
|
|
|
if af_stop_event:
|
|
af_stop_event.set()
|
|
|
|
af_thread.join(timeout=2)
|
|
af_thread = None
|
|
af_stop_event = None
|
|
|
|
if current_bridge:
|
|
fixed_bridge_ports.pop(current_bridge, None)
|
|
current_bridge = None
|
|
|
|
# close db pool if shared.web_loop is available; otherwise leave to main
|
|
try:
|
|
web_loop = getattr(shared_objects, "web_loop", None)
|
|
web_db = getattr(shared_objects, "db", None)
|
|
if web_db is not None and web_loop is not None:
|
|
asyncio.run_coroutine_threadsafe(web_db.close_pool(), web_loop)
|
|
except Exception:
|
|
logger.exception("Failed to schedule DB pool close")
|
|
|
|
logger.info("AF_PACKET sniffer stopped")
|
|
|
|
|
|
def get_sniffer_status() -> Dict[str, Dict[str, object]]:
|
|
"""
|
|
Return simple status per managed interface.
|
|
"""
|
|
running = af_thread.is_alive() if af_thread else False
|
|
out: Dict[str, Dict[str, object]] = {}
|
|
for iface in list(af_sockets.keys()):
|
|
out[iface] = {
|
|
"running": running,
|
|
"exists": check_interface_exists(iface),
|
|
"up": check_interface_up(iface),
|
|
}
|
|
return out
|