You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

497 lines
16 KiB
Python

import hashlib
import json
import socket
import time
from collections import Counter
from pathlib import Path
from udp_dump_capture import MikPacketAssembler
MAX_UDP_PAYLOAD = 65535
MAX_MIK_ARRAY = 256 * 1024 * 1024
MIK_PIXEL_BYTES = {
0x01: 1,
0x02: 2,
0x03: 3,
0x0A: 2,
0x12: 2,
}
RAW_ENCODINGS = {
"gray8": 1,
"gray16": 2,
"yuyv422": 2,
"bgr24": 3,
"rgb24": 3,
}
def inspect_mik_array(data):
if len(data) < 12:
return None
label_count = int.from_bytes(data[:4], "little")
video_offset = 4 + label_count * 40
if label_count > 1_000_000 or video_offset + 8 > len(data):
return None
header = data[video_offset:video_offset + 8]
width = int.from_bytes(header[:2], "little")
height = int.from_bytes(header[2:4], "little")
pixel_id = header[4]
padding = header[6]
bytes_per_pixel = MIK_PIXEL_BYTES.get(pixel_id)
if not bytes_per_pixel or not (1 <= width <= 8192 and 1 <= height <= 8192):
return None
expected = video_offset + 8 + (width * bytes_per_pixel + padding) * height
if expected > len(data):
return None
return {
"width": width,
"height": height,
"pixel_id": pixel_id,
"row_padding": padding,
"labels": label_count,
"array_bytes": len(data),
"expected_bytes": expected,
}
def _mik_candidate(payloads):
assembler = MikPacketAssembler()
header_count = 0
starts = 0
ends = 0
arrays = []
for payload in payloads:
if len(payload) < 8:
continue
flags = payload[1]
packet_number = payload[3]
value = int.from_bytes(payload[4:8], "little")
if flags & ~0x03:
continue
if flags & 0x02:
if packet_number != 0 or value <= 0 or value > MAX_MIK_ARRAY:
continue
starts += 1
elif value > MAX_MIK_ARRAY:
continue
header_count += 1
ends += int(bool(flags & 0x01))
try:
array = assembler.push(payload)
except ValueError:
continue
if array is not None:
arrays.append(array)
frames = [frame for frame in map(inspect_mik_array, arrays) if frame]
if frames:
frame = frames[0]
return {
"kind": "mik_video",
"confidence": 100,
"evidence": {
"matching_headers": header_count,
"start_packets": starts,
"end_packets": ends,
"complete_arrays": len(arrays),
"valid_video_arrays": len(frames),
"dropped_arrays": assembler.dropped_arrays,
},
"frame": frame,
"recommended": {
"source_mode": "udp_mik_live",
"quality": f"{frame['width']}x{frame['height']}",
},
}
ratio = header_count / max(1, len(payloads))
if header_count >= 3 and ratio >= 0.7 and starts:
return {
"kind": "mik_fragments",
"confidence": min(92, round(65 + ratio * 25)),
"evidence": {
"matching_headers": header_count,
"start_packets": starts,
"end_packets": ends,
"complete_arrays": len(arrays),
"dropped_arrays": assembler.dropped_arrays,
},
"recommended": {"source_mode": "udp_mik_live"},
}
return None
def _rtp_parts(payload):
if len(payload) < 12 or payload[0] >> 6 != 2:
return None
cc = payload[0] & 0x0F
offset = 12 + cc * 4
if offset > len(payload):
return None
if payload[0] & 0x10:
if offset + 4 > len(payload):
return None
words = int.from_bytes(payload[offset + 2:offset + 4], "big")
offset += 4 + words * 4
if offset > len(payload):
return None
return {
"payload_type": payload[1] & 0x7F,
"sequence": int.from_bytes(payload[2:4], "big"),
"timestamp": int.from_bytes(payload[4:8], "big"),
"ssrc": int.from_bytes(payload[8:12], "big"),
"payload": payload[offset:],
}
def _is_mpeg_ts(data):
return len(data) >= 188 and len(data) % 188 == 0 and all(
data[index] == 0x47 for index in range(0, len(data), 188)
)
def _rtp_candidate(payloads):
headers = [header for header in map(_rtp_parts, payloads) if header]
if len(headers) < 2 or len(headers) / max(1, len(payloads)) < 0.8:
return None
ssrc, ssrc_count = Counter(item["ssrc"] for item in headers).most_common(1)[0]
payload_type, type_count = Counter(item["payload_type"] for item in headers).most_common(1)[0]
sequential = sum(
((current["sequence"] - previous["sequence"]) & 0xFFFF) == 1
for previous, current in zip(headers, headers[1:])
)
ts_packets = sum(_is_mpeg_ts(item["payload"]) for item in headers)
confidence = 75
if ssrc_count / len(headers) >= 0.9 and type_count / len(headers) >= 0.9:
confidence += 10
if sequential / max(1, len(headers) - 1) >= 0.7:
confidence += 10
return {
"kind": "rtp_mpeg_ts" if ts_packets else "rtp",
"confidence": min(98, confidence),
"evidence": {
"rtp_packets": len(headers),
"payload_type": payload_type,
"ssrc": f"0x{ssrc:08x}",
"sequential_pairs": sequential,
"mpeg_ts_payloads": ts_packets,
},
}
def _encoded_candidate(payloads):
ts_packets = sum(_is_mpeg_ts(payload) for payload in payloads)
if ts_packets and ts_packets / len(payloads) >= 0.7:
return {
"kind": "mpeg_ts",
"confidence": 99,
"evidence": {"mpeg_ts_datagrams": ts_packets},
}
jpeg = sum(
payload.startswith(b"\xff\xd8\xff") and payload.rstrip().endswith(b"\xff\xd9")
for payload in payloads
)
png = sum(
payload.startswith(b"\x89PNG\r\n\x1a\n") and b"IEND" in payload[-32:]
for payload in payloads
)
if jpeg or png:
kind = "jpeg" if jpeg >= png else "png"
count = max(jpeg, png)
return {
"kind": kind,
"confidence": 100,
"evidence": {"complete_images": count},
"recommended": {
"source_mode": "udp_delimited_live",
"frame_encoding": "auto",
},
}
start_code_packets = 0
h264_packets = 0
h265_packets = 0
for payload in payloads:
offset = 4 if payload.startswith(b"\x00\x00\x00\x01") else 3
if offset == 3 and not payload.startswith(b"\x00\x00\x01"):
continue
if len(payload) <= offset:
continue
start_code_packets += 1
h264_packets += int(1 <= (payload[offset] & 0x1F) <= 23)
h265_packets += int(((payload[offset] >> 1) & 0x3F) <= 40)
if start_code_packets:
kind = "h264_annex_b" if h264_packets >= h265_packets else "h265_annex_b"
return {
"kind": kind,
"confidence": 90,
"evidence": {"start_code_datagrams": start_code_packets},
}
return None
def _raw_candidate(payloads, width, height, separator, configured_encoding):
if width <= 0 or height <= 0:
return None
separator_payload = bytes((separator & 0xFF,))
groups = []
current = 0
separator_packets = 0
for payload in payloads:
if payload == separator_payload:
separator_packets += 1
if current:
groups.append(current)
current = 0
else:
current += len(payload)
if current:
groups.append(current)
encodings = (
{configured_encoding: RAW_ENCODINGS[configured_encoding]}
if configured_encoding in RAW_ENCODINGS
else RAW_ENCODINGS
)
matches = []
total = sum(len(payload) for payload in payloads if payload != separator_payload)
for encoding, bytes_per_pixel in encodings.items():
expected = width * height * bytes_per_pixel
exact_groups = sum(size == expected for size in groups)
complete_frames = total // expected
remainder = total % expected
if exact_groups:
confidence = 99
elif separator_packets and complete_frames and remainder <= max(map(len, payloads)):
confidence = 78
elif not separator_packets and complete_frames:
confidence = 55
else:
continue
matches.append((confidence, encoding, expected, exact_groups, complete_frames, remainder))
if not matches:
return None
matches.sort(reverse=True)
confidence, encoding, expected, exact_groups, complete_frames, remainder = matches[0]
same_size = sorted({
candidate_encoding
for _, candidate_encoding, candidate_size, *_ in matches
if candidate_size == expected
})
ambiguous = len(same_size) > 1 and configured_encoding not in RAW_ENCODINGS
recommended = {
"source_mode": "udp_delimited_live",
"quality": f"{width}x{height}",
"separator_byte": separator,
}
if not ambiguous:
recommended["frame_encoding"] = encoding
return {
"kind": "raw_delimited" if separator_packets else "raw_stream",
"confidence": confidence,
"evidence": {
"separator_packets": separator_packets,
"expected_frame_bytes": expected,
"exact_frame_groups": exact_groups,
"complete_frame_equivalents": complete_frames,
"trailing_bytes": remainder,
"possible_encodings": same_size,
},
"frame": {"width": width, "height": height, "encoding": encoding},
"recommended": recommended,
}
def _text_candidate(payloads):
if not payloads:
return None
sample = payloads[0][:8192]
try:
text = sample.decode("utf-8")
except UnicodeDecodeError:
return None
printable = sum(character.isprintable() or character in "\r\n\t" for character in text)
if not text or printable / len(text) < 0.9:
return None
try:
json.loads(text)
kind = "json"
confidence = 100
except json.JSONDecodeError:
kind = "text"
confidence = 85
return {
"kind": kind,
"confidence": confidence,
"evidence": {"preview": text[:160]},
}
def analyze_udp_records(records, width=0, height=0, separator=0, frame_encoding="auto"):
payloads = [record["payload"] for record in records]
candidates = [
candidate
for candidate in (
_mik_candidate(payloads),
_rtp_candidate(payloads),
_encoded_candidate(payloads),
_raw_candidate(payloads, int(width), int(height), int(separator), frame_encoding),
_text_candidate(payloads),
)
if candidate is not None
]
candidates.sort(key=lambda candidate: candidate["confidence"], reverse=True)
detected = candidates[0] if candidates else {
"kind": "unknown",
"confidence": 0,
"evidence": {"reason": "no known structure matched"},
}
return {"detected": detected, "candidates": candidates}
def capture_udp_records(host, port, duration=3.0, max_packets=4096, max_bytes=32 * 1024 * 1024):
host = str(host or "0.0.0.0").strip() or "0.0.0.0"
port = max(1, min(65535, int(port)))
duration = max(0.2, min(15.0, float(duration)))
max_packets = max(1, min(32768, int(max_packets)))
max_bytes = max(MAX_UDP_PAYLOAD, min(256 * 1024 * 1024, int(max_bytes)))
records = []
total = 0
truncated = False
started = time.monotonic()
deadline = started + duration
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 16 * 1024 * 1024)
sock.bind((host, port))
bound_host, bound_port = sock.getsockname()
while len(records) < max_packets:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
sock.settimeout(min(0.25, remaining))
try:
payload, address = sock.recvfrom(MAX_UDP_PAYLOAD)
except socket.timeout:
continue
if total + len(payload) > max_bytes:
truncated = True
break
records.append({
"timestamp_ns": time.time_ns(),
"address": (str(address[0]), int(address[1])),
"payload": payload,
})
total += len(payload)
truncated = truncated or len(records) >= max_packets
finally:
sock.close()
return records, {
"listen_host": bound_host,
"listen_port": bound_port,
"elapsed_sec": round(time.monotonic() - started, 3),
"truncated": truncated,
}
def save_udp_records(records, destination_port, output_dir):
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
suffix = f"{time.time_ns() % 1_000_000_000:09d}"
dump_path = output_dir / f"udp_probe_{stamp}_{suffix}.udp"
report_path = dump_path.with_suffix(".json")
digest = hashlib.sha256()
packet_meta = []
offset = 0
with dump_path.open("wb") as stream:
for index, record in enumerate(records):
payload = record["payload"]
envelope = int(destination_port).to_bytes(2, "little") + len(payload).to_bytes(2, "little")
stream.write(envelope)
stream.write(payload)
digest.update(envelope)
digest.update(payload)
packet_meta.append({
"index": index,
"timestamp_ns": record["timestamp_ns"],
"source_ip": record["address"][0],
"source_port": record["address"][1],
"payload_size": len(payload),
"payload_sha256": hashlib.sha256(payload).hexdigest(),
"dump_offset": offset,
})
offset += len(envelope) + len(payload)
report = {
"format": "uint16_le destination_port, uint16_le payload_size, payload bytes",
"destination_port": int(destination_port),
"packets": packet_meta,
"dump_name": dump_path.name,
"dump_size": dump_path.stat().st_size,
"dump_sha256": digest.hexdigest(),
}
report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
return dump_path, report_path, report
def run_udp_probe(
host,
port,
output_dir,
width=0,
height=0,
separator=0,
frame_encoding="auto",
duration=3.0,
):
records, capture = capture_udp_records(host, port, duration=duration)
analysis = analyze_udp_records(records, width, height, separator, frame_encoding)
sizes = Counter(len(record["payload"]) for record in records)
sources = Counter(f"{record['address'][0]}:{record['address'][1]}" for record in records)
if len(records) > 1:
span = (records[-1]["timestamp_ns"] - records[0]["timestamp_ns"]) / 1e9
packet_rate = (len(records) - 1) / max(span, 1e-9)
else:
packet_rate = 0.0
sample_indexes = sorted(set(
list(range(min(3, len(records))))
+ ([len(records) - 1] if records else [])
))
samples = []
for index in sample_indexes:
payload = records[index]["payload"]
samples.append({
"index": index,
"source": f"{records[index]['address'][0]}:{records[index]['address'][1]}",
"size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
"head_hex": payload[:64].hex(" "),
"tail_hex": payload[-32:].hex(" ") if len(payload) > 64 else "",
"ascii": "".join(chr(byte) if 32 <= byte < 127 else "." for byte in payload[:64]),
})
exact_capture = None
if records:
dump_path, report_path, report = save_udp_records(records, port, output_dir)
exact_capture = {
"dump_name": dump_path.name,
"report_name": report_path.name,
"bytes": report["dump_size"],
"sha256": report["dump_sha256"],
}
return {
**capture,
"packets": len(records),
"payload_bytes": sum(len(record["payload"]) for record in records),
"packets_per_sec": round(packet_rate, 1),
"sources": [{"address": address, "packets": count} for address, count in sources.most_common()],
"sizes": [{"bytes": size, "packets": count} for size, count in sizes.most_common(12)],
"samples": samples,
"exact_capture": exact_capture,
**analysis,
}