Compare commits
206 Commits
2c6cb2b7d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9106cac2a2 | ||
| 68827ed7e3 | |||
| a4b19f8c3e | |||
| 6e7a1ccb1b | |||
| c9c1d346fd | |||
| a900fb8f8b | |||
| f24a230f9d | |||
| 8929878f13 | |||
| a9ff3a2987 | |||
| 10f0e518c7 | |||
| c40ddf4ded | |||
| bf63c7c1a9 | |||
| 1614d22257 | |||
| b4e4e27c4b | |||
| 3eb39a71ad | |||
| 945b259ebb | |||
| b5c6409f85 | |||
| 9c982e361c | |||
| 76256d56f2 | |||
| 3ca1d52972 | |||
| ede4b3e78d | |||
| ead2d5f217 | |||
| d90c5e1650 | |||
| 794a727dcb | |||
| c9735faf46 | |||
| b974b45bb2 | |||
| 89f1f00b16 | |||
| 15952ef125 | |||
| bef04dbe31 | |||
| a578b08041 | |||
| f554ddb235 | |||
| af17029388 | |||
| 9c6eaaa7b2 | |||
| 4521b9d99e | |||
| 9ee47284e0 | |||
| cb4c6ff564 | |||
| 0afe49053c | |||
| 8153dc283c | |||
| 74b961e152 | |||
| 8f6d795e87 | |||
| ecb7d7f258 | |||
| ae432b7437 | |||
| 72bab93aa3 | |||
| cedc52eb8d | |||
| 5f6eedf859 | |||
| 1a0d220f11 | |||
| 84fa1d3628 | |||
| cb73cbac09 | |||
| da6436f423 | |||
| a0efa8ba27 | |||
| de6edd6b68 | |||
| dbd052a7ac | |||
| d9cb501879 | |||
| 95c0302b6b | |||
| 945767f0ce | |||
| 0463075782 | |||
| f758901f83 | |||
| ce35be1e1e | |||
| e8bed95162 | |||
| f0f3f9861a | |||
| 87c4ef04e9 | |||
| c2fb35155c | |||
| 32aa17e0cf | |||
| c370374a8a | |||
| 57b0ac5b25 | |||
| fefbefc5e1 | |||
| 0e4dfd6859 | |||
| 1489c3535f | |||
| 6fa0ab0ee1 | |||
| 234827f5bc | |||
| a6825dd790 | |||
| c592671e6f | |||
| 0e8f08251e | |||
| ce53ef5101 | |||
| b836bf3f02 | |||
| 650b92d2c5 | |||
| f303b6de6a | |||
| ab4112e8b9 | |||
| 76dda73827 | |||
| 40d8b15417 | |||
| e919853d07 | |||
| 8efe797220 | |||
| 3f0e3c5400 | |||
| 6534f2c4fa | |||
| 665c0b4475 | |||
| c57d6ecc67 | |||
| 4c491cf4b1 | |||
| d2179b2813 | |||
| a49ee5b9bd | |||
| a111d5ea00 | |||
| a388f253ef | |||
| dc76daf217 | |||
| 5ea4dd388e | |||
| c41e68c0f3 | |||
| 8de4c880b0 | |||
| e81def5295 | |||
| eb2f2feba8 | |||
| 09b4add62b | |||
| 22bb6b8526 | |||
| dfad09fa21 | |||
| fec6ec29c7 | |||
| 9fc2079e86 | |||
| 01f9f6b5bd | |||
| 3f0331762e | |||
| 3ea07bf1df | |||
| 02f76144fa | |||
| 1fdf6ae3b9 | |||
| 13cc2612a2 | |||
| cef433f9d7 | |||
| 29986d5073 | |||
| ac849ba3fe | |||
| e3c683aea6 | |||
| f53bfe2d64 | |||
| 191e3e0da4 | |||
| 0c69daf229 | |||
| f55e899fc5 | |||
| 62ac1d4300 | |||
| ec411610c8 | |||
| e73726fd1b | |||
| 6ad2dd2435 | |||
| 5aac5974f5 | |||
| 264da68f11 | |||
| a282b89d45 | |||
| 2bdbe230d1 | |||
| e099f840d2 | |||
| dc3f7c0abd | |||
| c19fab4d32 | |||
| 8e6c759cb8 | |||
| 299f2e9978 | |||
| 161ed0f145 | |||
| 79b2f1e673 | |||
| f9bbe9b73c | |||
| a98054cb5b | |||
| 3d28657229 | |||
| c18f56000d | |||
| f96315918e | |||
| 47127234a4 | |||
| e3589fd7c3 | |||
| 25bb7be29d | |||
| 991f26e5f2 | |||
| 2aca2760df | |||
| b60a1d3118 | |||
| 7b9a7d3a4b | |||
| e532573db9 | |||
| d06c8adcdd | |||
| 7812f8715f | |||
| 0f2a342b75 | |||
| 613c8b8c5a | |||
| bd37cdf628 | |||
| a1dbbcb8d5 | |||
| 6689f01974 | |||
| f8647ab3ed | |||
| 3fc5b6db48 | |||
| e9ff417df1 | |||
| b0d1528142 | |||
| 2a1c53c419 | |||
| 2ec5e21e94 | |||
| 153bd4e7c8 | |||
| 7c08aed33a | |||
| 60634a77eb | |||
| ed54050d6b | |||
| 0dff3ade09 | |||
| c48d93642a | |||
| caf6799dd2 | |||
| 8fe9a06bfa | |||
| 969edeab86 | |||
| bff2e62a97 | |||
| 87bad13729 | |||
| 2033d2fce2 | |||
| 131d0eabe8 | |||
| 1ad3eecee0 | |||
| 5a7bce647b | |||
| 1d5750dc00 | |||
| 9e31902da6 | |||
| ba23be3742 | |||
| e837937ec0 | |||
| 3d8a448a25 | |||
| 1a19a819d8 | |||
| 50e56be8fd | |||
| 5bd8d86f0d | |||
| 32b1d0947f | |||
| 9af9be92a1 | |||
| 6183e18045 | |||
| 5268f8dd4e | |||
| 95d922549d | |||
| 2c3ce6bc86 | |||
| c247428bf3 | |||
| 7c5acbf758 | |||
| 216f8464f2 | |||
| f33c708cce | |||
| 7db2816660 | |||
| cbd7dacd24 | |||
| e844d79ac0 | |||
| 80352da7fd | |||
| c3079f3744 | |||
| 49e6e30a87 | |||
| 3a5eb0a46d | |||
| 5930794a4e | |||
| 90da926415 | |||
| a31ad6d11b | |||
| 7e89dcd8a1 | |||
| 64d3a973bc | |||
| eb1eef23c4 | |||
| b79e5cfbc7 | |||
| 6fcff2d042 | |||
| 60fd926fc6 |
@@ -3,7 +3,7 @@ name: Build and Deploy MITM Webserver
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # oder der Branch, den du automatisch bauen willst
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -12,8 +12,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
cd /opt/mitm-webserver
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git reset --hard origin/main
|
||||
git pull
|
||||
|
||||
- name: Build Frontend
|
||||
run: |
|
||||
@@ -32,3 +33,48 @@ jobs:
|
||||
- name: Restart Backend
|
||||
run: |
|
||||
sudo systemctl restart mitm-backend
|
||||
|
||||
traffic_target:
|
||||
runs-on: traffic-target
|
||||
steps:
|
||||
- name: Update target checkout and detect script changes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/opt/mitm-webserver"
|
||||
TARGET_SCRIPT="tools/traffic-target.sh"
|
||||
RESTART_MARKER="/tmp/traffic-target-restart-required"
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
OLD_REV="$(git rev-parse HEAD)"
|
||||
git fetch origin main
|
||||
NEW_REV="$(git rev-parse origin/main)"
|
||||
|
||||
if git diff --quiet "$OLD_REV" "$NEW_REV" -- "$TARGET_SCRIPT"; then
|
||||
rm -f "$RESTART_MARKER"
|
||||
echo "traffic-target.sh unchanged"
|
||||
else
|
||||
touch "$RESTART_MARKER"
|
||||
echo "traffic-target.sh changed"
|
||||
fi
|
||||
|
||||
git checkout main
|
||||
git merge --ff-only origin/main
|
||||
|
||||
- name: Restart traffic target daemon when needed
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
RESTART_MARKER="/tmp/traffic-target-restart-required"
|
||||
TARGET_SERVICE="mitm-traffic-target"
|
||||
|
||||
if [ -f "$RESTART_MARKER" ]; then
|
||||
systemctl restart "$TARGET_SERVICE"
|
||||
rm -f "$RESTART_MARKER"
|
||||
echo "Restarted ${TARGET_SERVICE}"
|
||||
else
|
||||
echo "No restart required"
|
||||
fi
|
||||
|
||||
23
backend/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
BACKEND_DB_DSN=postgresql://mitm_user:mitm_password@localhost:5432/mitm_db
|
||||
BACKEND_LOG_LEVEL=DEBUG
|
||||
BACKEND_DB_POOL_MIN_SIZE=1
|
||||
BACKEND_DB_POOL_MAX_SIZE=5
|
||||
BACKEND_BROADCAST_QUEUE_MAXSIZE=1024
|
||||
BACKEND_PACKET_TRACKER_FINALIZE_DELAY_SECONDS=0.25
|
||||
BACKEND_PACKET_TRACKER_RETENTION_SECONDS=10.0
|
||||
BACKEND_PACKET_TRACKER_MIN_FLUSH_INTERVAL_SECONDS=0.05
|
||||
BACKEND_PACKET_TRACKER_PERSIST_TIMEOUT_SECONDS=2.0
|
||||
BACKEND_PACKET_TRACKER_STOP_JOIN_TIMEOUT_SECONDS=2.0
|
||||
BACKEND_PACKET_TRACKER_REJECT_CORRELATION_WINDOW_SECONDS=1.0
|
||||
BACKEND_SNIFFER_BUFFER_CAPACITY=20000
|
||||
BACKEND_SNIFFER_SOCKET_RCVBUF_BYTES=4194304
|
||||
BACKEND_SNIFFER_SELECTOR_TIMEOUT_SECONDS=1.0
|
||||
BACKEND_SNIFFER_RECV_BYTES=65536
|
||||
BACKEND_SNIFFER_BUFFER_DRAIN_INTERVAL_SECONDS=5.0
|
||||
BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS=2.0
|
||||
BACKEND_BRIDGE_BPF_BUILD_DIR=/tmp/mitm-bpf
|
||||
BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS=3.0
|
||||
BACKEND_TELEMETRY_READER_JOIN_TIMEOUT_SECONDS=2.0
|
||||
BACKEND_NDPI_MAX_FLOWS=200000
|
||||
BACKEND_NDPI_FLOW_TTL_SECONDS=120.0
|
||||
BACKEND_NDPI_CLEANUP_INTERVAL_PACKETS=10000
|
||||
@@ -2,6 +2,10 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential libpcap-dev pkg-config tshark \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
23
backend/example_scripts/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Example NFQUEUE Scripts
|
||||
|
||||
Files in this folder are treated as protected example scripts by the API:
|
||||
|
||||
- `*.py`: script source
|
||||
- `*-requirements.txt`: optional pip requirements copied and installed into the script venv
|
||||
- `*.deploy.json`: optional deployment settings for startup auto-deploy
|
||||
|
||||
Protected behavior:
|
||||
|
||||
- scripts are synced from this folder into `/srv/fw-scripts` on backend startup
|
||||
- scripts in this folder cannot be overwritten, edited, or deleted via the API
|
||||
- scripts with a deploy config containing `qnum` are auto-started as systemd services
|
||||
|
||||
Example deploy file:
|
||||
|
||||
```json
|
||||
{
|
||||
"qnum": 1,
|
||||
"enable_at_boot": true,
|
||||
"extra_args": "--log-level INFO"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
netfilterqueue
|
||||
54
backend/example_scripts/chaos_delay_jitter.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal NFQUEUE example: add random delay and jitter, but do not drop packets."""
|
||||
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
from netfilterqueue import NetfilterQueue
|
||||
|
||||
# Demo tuning values.
|
||||
BASE_DELAY_MS = 40
|
||||
JITTER_MS = 120
|
||||
|
||||
nfq = NetfilterQueue()
|
||||
|
||||
|
||||
def _handle_packet(packet) -> None:
|
||||
delay_ms = BASE_DELAY_MS + random.uniform(0, JITTER_MS)
|
||||
time.sleep(delay_ms / 1000.0)
|
||||
packet.accept()
|
||||
|
||||
|
||||
def _stop(_sig, _frame) -> None:
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: chaos_delay_jitter.py <qnum>", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
qnum = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print("qnum must be an integer", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
|
||||
nfq.bind(qnum, _handle_packet)
|
||||
try:
|
||||
nfq.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
nfq.unbind()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
netfilterqueue
|
||||
scapy
|
||||
68
backend/example_scripts/dns_rewrite_example_to_pwned.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal NFQUEUE example: rewrite DNS queries from example.com to pwned.com."""
|
||||
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from netfilterqueue import NetfilterQueue
|
||||
from scapy.all import DNS, DNSQR, IP, UDP
|
||||
|
||||
SOURCE_QNAME = b"example.com."
|
||||
TARGET_QNAME = b"pwned.com."
|
||||
|
||||
nfq = NetfilterQueue()
|
||||
|
||||
|
||||
def _handle_packet(packet) -> None:
|
||||
try:
|
||||
ip = IP(packet.get_payload())
|
||||
if ip.haslayer(UDP) and ip.haslayer(DNS) and ip.haslayer(DNSQR):
|
||||
dns = ip[DNS]
|
||||
query = ip[DNSQR]
|
||||
|
||||
# Only touch DNS requests for exactly example.com.
|
||||
if dns.qr == 0 and query.qname == SOURCE_QNAME:
|
||||
query.qname = TARGET_QNAME
|
||||
# delete fields so scapy re-calculates them
|
||||
del ip.len
|
||||
del ip.chksum
|
||||
del ip[UDP].len
|
||||
del ip[UDP].chksum
|
||||
packet.set_payload(bytes(ip))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
packet.accept()
|
||||
|
||||
|
||||
def _stop(_sig, _frame) -> None:
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: dns_rewrite_example_to_pwned.py <qnum>", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
qnum = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print("qnum must be an integer", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
|
||||
nfq.bind(qnum, _handle_packet)
|
||||
try:
|
||||
nfq.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
nfq.unbind()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
backend/example_scripts/hello_nfqueue-requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
netfilterqueue
|
||||
49
backend/example_scripts/hello_nfqueue.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal NFQUEUE example: accept every packet from a queue."""
|
||||
|
||||
import signal
|
||||
import sys
|
||||
|
||||
try:
|
||||
from netfilterqueue import NetfilterQueue
|
||||
except Exception as exc:
|
||||
print(f"Failed to import netfilterqueue: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
nfq = NetfilterQueue()
|
||||
|
||||
|
||||
def _handle_packet(packet) -> None:
|
||||
packet.accept()
|
||||
|
||||
|
||||
def _stop(_sig, _frame) -> None:
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: hello_nfqueue.py <qnum>", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
qnum = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print("qnum must be an integer", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
|
||||
nfq.bind(qnum, _handle_packet)
|
||||
try:
|
||||
nfq.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
nfq.unbind()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
2
backend/example_scripts/icmp_drop-requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
netfilterqueue
|
||||
scapy
|
||||
38
backend/example_scripts/icmp_drop.py
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
#!/usr/bin/env python3
|
||||
# drop_icmp_v4.py
|
||||
# Requirements: NetfilterQueue, scapy
|
||||
|
||||
import sys
|
||||
from netfilterqueue import NetfilterQueue
|
||||
from scapy.all import IP
|
||||
|
||||
def on_packet(pkt):
|
||||
data = pkt.get_payload()
|
||||
try:
|
||||
ip = IP(data)
|
||||
# IPv4 ICMP protocol number == 1
|
||||
if ip.proto == 1:
|
||||
pkt.drop()
|
||||
return
|
||||
except Exception:
|
||||
# parsing error -> accept (conservative choice)
|
||||
pass
|
||||
pkt.accept()
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: drop_icmp_v4.py <QUEUE_NUM>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
qnum = int(sys.argv[1])
|
||||
nfq = NetfilterQueue()
|
||||
nfq.bind(qnum, on_packet)
|
||||
try:
|
||||
nfq.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
nfq.unbind()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
backend/example_scripts/packet_loss-requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
netfilterqueue
|
||||
67
backend/example_scripts/packet_loss.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal NFQUEUE example: randomly drop packets by percentage."""
|
||||
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from netfilterqueue import NetfilterQueue
|
||||
|
||||
DEFAULT_LOSS_PERCENT = 10.0
|
||||
|
||||
nfq = NetfilterQueue()
|
||||
loss_percent = DEFAULT_LOSS_PERCENT
|
||||
|
||||
|
||||
def _handle_packet(packet) -> None:
|
||||
if random.random() < (loss_percent / 100.0):
|
||||
packet.drop()
|
||||
return
|
||||
|
||||
packet.accept()
|
||||
|
||||
|
||||
def _stop(_sig, _frame) -> None:
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global loss_percent
|
||||
|
||||
if len(sys.argv) not in (2, 3):
|
||||
print("Usage: packet_loss.py <qnum> [loss_percent]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
qnum = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print("qnum must be an integer", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if len(sys.argv) == 3:
|
||||
try:
|
||||
loss_percent = float(sys.argv[2])
|
||||
except ValueError:
|
||||
print("loss_percent must be a number between 0 and 100", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not 0.0 <= loss_percent <= 100.0:
|
||||
print("loss_percent must be between 0 and 100", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
|
||||
nfq.bind(qnum, _handle_packet)
|
||||
try:
|
||||
nfq.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
nfq.unbind()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,13 +1,20 @@
|
||||
"""Netplan schema models used by bridge/network configuration APIs."""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
class Nameservers(BaseModel):
|
||||
"""DNS nameserver configuration."""
|
||||
|
||||
addresses: List[str] = Field(default_factory=list)
|
||||
search: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EthernetConfig(BaseModel):
|
||||
"""Netplan ethernet interface configuration."""
|
||||
|
||||
dhcp4: Optional[bool] = None
|
||||
dhcp6: Optional[bool] = None
|
||||
addresses: Optional[List[str]] = None
|
||||
@@ -18,44 +25,23 @@ class EthernetConfig(BaseModel):
|
||||
|
||||
|
||||
class BridgeConfig(BaseModel):
|
||||
interfaces: List[str] = Field(default_factory=list) # ["eth1", "eth2"]
|
||||
"""Netplan bridge configuration."""
|
||||
|
||||
interfaces: List[str] = Field(default_factory=list)
|
||||
dhcp4: Optional[bool] = None
|
||||
dhcp6: Optional[bool] = None
|
||||
addresses: Optional[List[str]] = None
|
||||
gateway4: Optional[str] = None
|
||||
gateway6: Optional[str] = None
|
||||
nameservers: Optional[Nameservers] = None
|
||||
parameters: Optional[dict] = None # allows spanning-tree, port-priority, forward-delay, etc.
|
||||
parameters: Optional[dict] = None
|
||||
optional: Optional[bool] = None
|
||||
|
||||
|
||||
class NetworkConfig(BaseModel):
|
||||
"""Top-level Netplan network object."""
|
||||
|
||||
version: int = 2
|
||||
renderer: Optional[str] = "networkd"
|
||||
ethernets: Dict[str, EthernetConfig] = Field(default_factory=dict)
|
||||
bridges: Dict[str, BridgeConfig] = Field(default_factory=dict)
|
||||
|
||||
|
||||
'''Example usage:
|
||||
{
|
||||
"version": 2,
|
||||
"renderer": "networkd",
|
||||
"ethernets": {
|
||||
"eth0": {
|
||||
"dhcp4": false,
|
||||
"addresses": ["192.168.10.20/24"],
|
||||
"gateway4": "192.168.10.1",
|
||||
"nameservers": {
|
||||
"addresses": ["1.1.1.1", "8.8.8.8"]
|
||||
}
|
||||
},
|
||||
"eth1": {},
|
||||
"eth2": {}
|
||||
},
|
||||
"bridges": {
|
||||
"br0": {
|
||||
"interfaces": ["eth1", "eth2"],
|
||||
"dhcp4": true
|
||||
}
|
||||
}
|
||||
}'''
|
||||
|
||||
132
backend/src/Models/packets.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Pydantic model for packet rows returned by the backend."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
||||
|
||||
|
||||
class PacketObservationModel(BaseModel):
|
||||
"""One raw-capture or telemetry observation that contributed to a packet row."""
|
||||
|
||||
observation_type: Literal["capture", "telemetry"]
|
||||
source: str
|
||||
iface: Optional[str] = None
|
||||
timestamp: Optional[str] = None
|
||||
event_type: Optional[str] = None
|
||||
capture_mode: Optional[str] = None
|
||||
capture_session_id: Optional[str] = None
|
||||
session_label: Optional[str] = None
|
||||
session_kind: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class PacketDBModel(BaseModel):
|
||||
"""Normalized packet representation used across DB and API layers."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 123,
|
||||
"timestamp": "2026-03-05T12:34:56.789Z",
|
||||
"updated_at": "2026-03-05T12:34:56.900Z",
|
||||
"correlation_key": "pid:123456",
|
||||
"correlation_source": "kernel_mark",
|
||||
"packet_id": "123456",
|
||||
"packet_uid": "9f6d3af0d3c81cb20ee8e7d32df7c56414460542",
|
||||
"skb_mark": 123456,
|
||||
"ingress_if": "eth0",
|
||||
"egress_if": "eth1",
|
||||
"capture_iface": None,
|
||||
"src_mac": "aa:bb:cc:dd:ee:ff",
|
||||
"dst_mac": "11:22:33:44:55:66",
|
||||
"eth_type_raw": 2048,
|
||||
"eth_type": "IPv4",
|
||||
"ip_proto_raw": 6,
|
||||
"ip_proto": "TCP",
|
||||
"src_ip": "192.168.1.10",
|
||||
"dst_ip": "192.168.1.1",
|
||||
"src_port": 54321,
|
||||
"dst_port": 80,
|
||||
"vlan_id": None,
|
||||
"length": 128,
|
||||
"raw_present": True,
|
||||
"capture_sources": ["tc_ingress_raw", "telemetry"],
|
||||
"raw_b64": "BASE64...",
|
||||
"app_protocol": "HTTP",
|
||||
"app_master_protocol": "HTTP",
|
||||
"app_category": "Web",
|
||||
"app_confidence": "high",
|
||||
"app_hostname": "example.org",
|
||||
"app_is_encrypted": False,
|
||||
"app_risk_score": 0,
|
||||
"dpi_metadata": {"method": "GET"},
|
||||
"capture_metadata": {"capture_mode": "tc_ingress", "packet_id": "123456"},
|
||||
"telemetry_metadata": {"event_type": "egress", "iface": "eth1", "packet_id": "123456"},
|
||||
"capture_observations": [
|
||||
{
|
||||
"observation_type": "capture",
|
||||
"source": "af_packet",
|
||||
"iface": "eth0",
|
||||
"timestamp": "2026-03-05T12:34:56.789000+00:00",
|
||||
"capture_mode": "af_packet",
|
||||
"capture_session_id": "session-1",
|
||||
"session_label": "br0",
|
||||
"session_kind": "bridge",
|
||||
}
|
||||
],
|
||||
"verdict": "accept",
|
||||
"verdict_reason": "egress-observed",
|
||||
"verdict_confidence": "high",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: Union[int, str]
|
||||
timestamp: datetime = Field(..., description="Packet timestamp in ISO format.")
|
||||
updated_at: Optional[datetime] = Field(None, description="Last DB update time for this row.")
|
||||
correlation_key: str = Field(..., description="Primary upsert key for this packet row.")
|
||||
correlation_source: Optional[str] = Field(None, description="How the correlation key was derived.")
|
||||
packet_id: Optional[str] = Field(None, description="Kernel-side packet identifier derived from skb mark.")
|
||||
packet_uid: Optional[str] = Field(None, description="Legacy hash-based packet identity fallback.")
|
||||
skb_mark: Optional[int] = Field(None, description="Raw skb mark observed in telemetry or capture header.")
|
||||
ingress_if: Optional[str] = None
|
||||
egress_if: Optional[str] = None
|
||||
capture_iface: Optional[str] = None
|
||||
src_mac: Optional[str] = None
|
||||
dst_mac: Optional[str] = None
|
||||
eth_type_raw: Optional[int] = Field(None, description="Numeric Ethernet type from the frame header.")
|
||||
eth_type: Optional[Union[int, str]] = None
|
||||
ip_proto_raw: Optional[int] = Field(None, description="Numeric IP protocol / next-header value.")
|
||||
ip_proto: Optional[Union[int, str]] = None
|
||||
src_ip: Optional[IPvAnyAddress] = None
|
||||
dst_ip: Optional[IPvAnyAddress] = None
|
||||
src_port: Optional[int] = None
|
||||
dst_port: Optional[int] = None
|
||||
vlan_id: Optional[int] = None
|
||||
length: Optional[int] = None
|
||||
raw_present: Optional[bool] = Field(None, description="Whether raw packet bytes were captured for this row.")
|
||||
capture_sources: Optional[list[str]] = Field(None, description="Capture sources that contributed to this row.")
|
||||
flow_id: Optional[str] = Field(None, description="Derived flow identifier from tshark stream metadata, if available.")
|
||||
raw_b64: Optional[str] = Field(None, description="Base64-encoded packet bytes.")
|
||||
app_protocol: Optional[str] = Field(None, description="Detected application protocol.")
|
||||
app_master_protocol: Optional[str] = Field(None, description="Detected application master protocol.")
|
||||
app_category: Optional[str] = Field(None, description="Detected application category, if available.")
|
||||
app_confidence: Optional[str] = Field(None, description="Application detection confidence, if available.")
|
||||
app_hostname: Optional[str] = Field(None, description="Detected hostname/SNI, if available.")
|
||||
app_is_encrypted: Optional[bool] = Field(None, description="Whether detected protocol appears encrypted.")
|
||||
app_risk_score: Optional[int] = Field(None, description="Count/score of detected application risks.")
|
||||
dpi_metadata: Optional[dict] = Field(None, description="Raw metadata from DPI/flow enrichment.")
|
||||
capture_metadata: Optional[dict] = Field(None, description="Raw-capture metadata from the bridge tc ingress exporter.")
|
||||
telemetry_metadata: Optional[dict] = Field(None, description="Kernel telemetry details from eBPF collector.")
|
||||
capture_observations: Optional[list[PacketObservationModel]] = Field(
|
||||
None,
|
||||
description="Ordered list of raw-capture and telemetry observations merged into this packet row.",
|
||||
)
|
||||
verdict: Optional[str] = None
|
||||
verdict_reason: Optional[str] = None
|
||||
verdict_confidence: Optional[str] = None
|
||||
ingress_seen_at: Optional[datetime] = None
|
||||
egress_seen_at: Optional[datetime] = None
|
||||
verdict_seen_at: Optional[datetime] = None
|
||||
packets: Optional[int] = None
|
||||
655
backend/src/api/analysis_api.py
Normal file
@@ -0,0 +1,655 @@
|
||||
"""Analysis endpoints derived from captured packet history."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import src.shared_objects as shared
|
||||
from src.Models.packets import PacketDBModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class InterfaceHostEvidence(BaseModel):
|
||||
ip_address: Optional[str] = Field(None, description="Observed IP address for the host.")
|
||||
mac_address: Optional[str] = Field(None, description="Observed MAC address for the host.")
|
||||
packet_count: int = Field(..., description="How many packet observations supported this mapping.")
|
||||
last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this mapping.")
|
||||
source_on_ingress_count: int = Field(..., description="Packets where this endpoint appeared as the source on ingress.")
|
||||
destination_on_egress_count: int = Field(..., description="Packets where this endpoint appeared as the destination on egress.")
|
||||
|
||||
|
||||
class ProtocolLayerPathEvidence(BaseModel):
|
||||
ethernet_protocol: Optional[str] = Field(None, description="Ethernet protocol label for this path, if known.")
|
||||
ip_protocol: Optional[str] = Field(None, description="IP protocol label for this path, if known.")
|
||||
packet_count: int = Field(..., description="Packet observations supporting this path.")
|
||||
last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this path.")
|
||||
accept_count: int = Field(0, description="Packets with verdict=accept for this path.")
|
||||
drop_count: int = Field(0, description="Packets with verdict=drop for this path.")
|
||||
reject_count: int = Field(0, description="Packets with verdict=reject for this path.")
|
||||
unknown_count: int = Field(0, description="Packets with verdict pending/unknown or without a verdict.")
|
||||
|
||||
|
||||
class ProtocolEvidence(BaseModel):
|
||||
protocol: str = Field(..., description="Detected application or fallback transport/network protocol.")
|
||||
packet_count: int = Field(..., description="Packet observations supporting this interface-host-protocol mapping.")
|
||||
last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this protocol mapping.")
|
||||
accept_count: int = Field(0, description="Packets with verdict=accept for this protocol.")
|
||||
drop_count: int = Field(0, description="Packets with verdict=drop for this protocol.")
|
||||
reject_count: int = Field(0, description="Packets with verdict=reject for this protocol.")
|
||||
unknown_count: int = Field(0, description="Packets with verdict pending/unknown or without a verdict.")
|
||||
ethernet_protocol: Optional[str] = Field(None, description="Dominant Ethernet protocol associated with this protocol evidence.")
|
||||
ip_protocol: Optional[str] = Field(None, description="Dominant IP protocol associated with this protocol evidence.")
|
||||
layer_paths: List[ProtocolLayerPathEvidence] = Field(
|
||||
default_factory=list,
|
||||
description="Optional Ethernet/IP breakdown contributing to this protocol evidence.",
|
||||
)
|
||||
|
||||
|
||||
class InterfaceHostProtocolEvidence(InterfaceHostEvidence):
|
||||
protocols: List[ProtocolEvidence] = Field(default_factory=list, description="Protocols observed for this host on the interface.")
|
||||
|
||||
|
||||
class InterfaceAttachment(BaseModel):
|
||||
interface: str = Field(..., description="MITM machine interface name.")
|
||||
hosts: List[InterfaceHostEvidence] = Field(default_factory=list, description="Endpoints inferred to be attached to this interface.")
|
||||
|
||||
|
||||
class InterfaceProtocolAttachment(BaseModel):
|
||||
interface: str = Field(..., description="MITM machine interface name.")
|
||||
hosts: List[InterfaceHostProtocolEvidence] = Field(default_factory=list, description="Endpoints inferred to be attached to this interface, with protocol breakdown.")
|
||||
|
||||
|
||||
class InterfaceHostAnalysisResponse(BaseModel):
|
||||
since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.")
|
||||
interfaces: List[InterfaceAttachment] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"This is an inference from observed packet direction, not a kernel neighbor-table lookup.",
|
||||
"A host is inferred on an interface when it appears as source on ingress or as destination on egress on that interface.",
|
||||
"Broadcast and obviously incomplete endpoint records are ignored.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class InterfaceHostProtocolAnalysisResponse(BaseModel):
|
||||
since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.")
|
||||
interfaces: List[InterfaceProtocolAttachment] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"This is an inference from observed packet direction, not a kernel neighbor-table lookup.",
|
||||
"Each host can carry multiple protocols; protocols prefer app_protocol and fall back to lower-layer protocol names.",
|
||||
"Verdict counts are packet counts grouped per interface, host, and protocol.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class InterfaceProtocolPathEvidence(BaseModel):
|
||||
ingress_interface: Optional[str] = Field(None, description="Observed ingress interface for the packet path.")
|
||||
egress_interface: Optional[str] = Field(None, description="Observed egress interface for the packet path.")
|
||||
src_ip_address: Optional[str] = Field(None, description="Observed source IP address.")
|
||||
src_mac_address: Optional[str] = Field(None, description="Observed source MAC address.")
|
||||
dst_ip_address: Optional[str] = Field(None, description="Observed destination IP address.")
|
||||
dst_mac_address: Optional[str] = Field(None, description="Observed destination MAC address.")
|
||||
protocol: str = Field(..., description="Detected application or fallback protocol for the packet path.")
|
||||
ethernet_protocol: Optional[str] = Field(None, description="Dominant Ethernet protocol associated with this path.")
|
||||
ip_protocol: Optional[str] = Field(None, description="Dominant IP protocol associated with this path.")
|
||||
packet_count: int = Field(..., description="Packet observations supporting this end-to-end path.")
|
||||
last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this path.")
|
||||
accept_count: int = Field(0, description="Packets with verdict=accept for this path.")
|
||||
drop_count: int = Field(0, description="Packets with verdict=drop for this path.")
|
||||
reject_count: int = Field(0, description="Packets with verdict=reject for this path.")
|
||||
unknown_count: int = Field(0, description="Packets with verdict pending/unknown or without a verdict.")
|
||||
|
||||
|
||||
class InterfaceProtocolPathAnalysisResponse(BaseModel):
|
||||
since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.")
|
||||
paths: List[InterfaceProtocolPathEvidence] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"This Sankey view is built from packet paths, not from inferred interface-host attachment.",
|
||||
"Each row represents a grouped ingress -> source endpoint -> protocol -> destination endpoint -> egress path.",
|
||||
"Protocols prefer app_protocol and fall back to lower-layer protocol names.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ConversationEvidence(BaseModel):
|
||||
ingress_interface: Optional[str] = None
|
||||
egress_interface: Optional[str] = None
|
||||
src_ip_address: Optional[str] = None
|
||||
src_mac_address: Optional[str] = None
|
||||
dst_ip_address: Optional[str] = None
|
||||
dst_mac_address: Optional[str] = None
|
||||
src_port: Optional[int] = None
|
||||
dst_port: Optional[int] = None
|
||||
protocol: str
|
||||
ethernet_protocol: Optional[str] = None
|
||||
ip_protocol: Optional[str] = None
|
||||
hostnames: List[str] = Field(default_factory=list)
|
||||
flow_ids: List[str] = Field(default_factory=list)
|
||||
flow_count: int = 0
|
||||
packet_count: int
|
||||
byte_count: int
|
||||
duration_ms: int = 0
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
accept_count: int = 0
|
||||
drop_count: int = 0
|
||||
reject_count: int = 0
|
||||
unknown_count: int = 0
|
||||
|
||||
|
||||
class ConversationAnalysisResponse(BaseModel):
|
||||
since: Optional[datetime] = None
|
||||
conversations: List[ConversationEvidence] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"Conversations group directional traffic by source, destination, ports, and detected protocol.",
|
||||
"Byte counts come from packet lengths observed by the MITM and are useful for comparing session size.",
|
||||
"Hostname hints are inferred from app_hostname when present, including DNS, HTTP Host, and TLS SNI.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ConversationFlowSummaryEvidence(BaseModel):
|
||||
flow_id: str
|
||||
protocol: str
|
||||
packet_count: int
|
||||
byte_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
client_label: str
|
||||
server_label: str
|
||||
request_count: int = 0
|
||||
response_count: int = 0
|
||||
|
||||
|
||||
class ConversationFlowEventEvidence(BaseModel):
|
||||
flow_id: str
|
||||
timestamp: datetime
|
||||
kind: str
|
||||
label: str
|
||||
src_label: str
|
||||
dst_label: str
|
||||
packet_id: Optional[str | int] = None
|
||||
|
||||
|
||||
class ConversationFlowDetailResponse(BaseModel):
|
||||
since: Optional[datetime] = None
|
||||
packets: List[PacketDBModel] = Field(default_factory=list)
|
||||
flows: List[ConversationFlowSummaryEvidence] = Field(default_factory=list)
|
||||
events: List[ConversationFlowEventEvidence] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"This detail view is reconstructed from ordered captured packets for one directional conversation or flow.",
|
||||
"Events are inferred from HTTP metadata and TCP packet types, so lower-layer traffic may have fewer high-level annotations.",
|
||||
"If multiple flow ids exist for the same directional tuple, the drawer shows all matching packets in timestamp order.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class LabelCountEvidence(BaseModel):
|
||||
label: str
|
||||
packet_count: int
|
||||
|
||||
|
||||
class HostPeerEvidence(BaseModel):
|
||||
ip_address: Optional[str] = None
|
||||
mac_address: Optional[str] = None
|
||||
packet_count: int
|
||||
byte_count: int
|
||||
last_seen: datetime
|
||||
protocols: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostServiceEvidence(BaseModel):
|
||||
port: Optional[int] = None
|
||||
protocol: str
|
||||
packet_count: int
|
||||
byte_count: int
|
||||
last_seen: datetime
|
||||
hostnames: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostIntelligenceEvidence(BaseModel):
|
||||
ip_address: Optional[str] = None
|
||||
mac_address: Optional[str] = None
|
||||
packet_count: int
|
||||
byte_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
interfaces: List[str] = Field(default_factory=list)
|
||||
source_count: int
|
||||
destination_count: int
|
||||
hostnames: List[str] = Field(default_factory=list)
|
||||
top_protocols: List[LabelCountEvidence] = Field(default_factory=list)
|
||||
peers: List[HostPeerEvidence] = Field(default_factory=list)
|
||||
services: List[HostServiceEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostIntelligenceAnalysisResponse(BaseModel):
|
||||
since: Optional[datetime] = None
|
||||
hosts: List[HostIntelligenceEvidence] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"Host intelligence merges packet direction, protocol usage, peer relationships, and hostname enrichment.",
|
||||
"Services are inferred from traffic where the host appears as the destination on a specific port.",
|
||||
"Hostname hints come from detected app_hostname values and help turn IPs into recognizable assets.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DiscoveryActivityEvidence(BaseModel):
|
||||
category: str
|
||||
protocol: str
|
||||
ingress_interface: Optional[str] = None
|
||||
egress_interface: Optional[str] = None
|
||||
src_ip_address: Optional[str] = None
|
||||
src_mac_address: Optional[str] = None
|
||||
dst_ip_address: Optional[str] = None
|
||||
dst_mac_address: Optional[str] = None
|
||||
src_port: Optional[int] = None
|
||||
dst_port: Optional[int] = None
|
||||
hostnames: List[str] = Field(default_factory=list)
|
||||
packet_count: int
|
||||
byte_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class DiscoveryAnalysisResponse(BaseModel):
|
||||
since: Optional[datetime] = None
|
||||
activities: List[DiscoveryActivityEvidence] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"Discovery traffic highlights local network learning and service advertisement protocols.",
|
||||
"This includes ARP, DHCP, mDNS, SSDP, LLMNR, NBNS, and selected ICMPv6 discovery traffic.",
|
||||
"These views are useful for mapping who is present on the segment and which naming systems are active.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ScanCandidateEvidence(BaseModel):
|
||||
src_ip_address: Optional[str] = None
|
||||
src_mac_address: Optional[str] = None
|
||||
packet_count: int
|
||||
target_host_count: int
|
||||
target_port_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class BeaconCandidateEvidence(BaseModel):
|
||||
src_ip_address: Optional[str] = None
|
||||
src_mac_address: Optional[str] = None
|
||||
dst_ip_address: Optional[str] = None
|
||||
dst_mac_address: Optional[str] = None
|
||||
dst_port: Optional[int] = None
|
||||
protocol: str
|
||||
packet_count: int
|
||||
avg_interval_seconds: float
|
||||
jitter_ratio: float
|
||||
interval_samples: List[float] = Field(default_factory=list)
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class RareServiceEvidence(BaseModel):
|
||||
dst_ip_address: Optional[str] = None
|
||||
dst_mac_address: Optional[str] = None
|
||||
dst_port: Optional[int] = None
|
||||
protocol: str
|
||||
packet_count: int
|
||||
client_count: int
|
||||
hostnames: List[str] = Field(default_factory=list)
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class ResetHeavyPathEvidence(BaseModel):
|
||||
src_ip_address: Optional[str] = None
|
||||
src_mac_address: Optional[str] = None
|
||||
dst_ip_address: Optional[str] = None
|
||||
dst_mac_address: Optional[str] = None
|
||||
dst_port: Optional[int] = None
|
||||
total_packets: int
|
||||
reset_count: int
|
||||
reset_ratio: float
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class DropHeavyPathEvidence(BaseModel):
|
||||
src_ip_address: Optional[str] = None
|
||||
src_mac_address: Optional[str] = None
|
||||
dst_ip_address: Optional[str] = None
|
||||
dst_mac_address: Optional[str] = None
|
||||
protocol: str
|
||||
total_packets: int
|
||||
drop_count: int
|
||||
reject_count: int
|
||||
failure_ratio: float
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class AnomalyAnalysisResponse(BaseModel):
|
||||
since: Optional[datetime] = None
|
||||
scan_candidates: List[ScanCandidateEvidence] = Field(default_factory=list)
|
||||
beacon_candidates: List[BeaconCandidateEvidence] = Field(default_factory=list)
|
||||
rare_services: List[RareServiceEvidence] = Field(default_factory=list)
|
||||
reset_heavy_paths: List[ResetHeavyPathEvidence] = Field(default_factory=list)
|
||||
drop_heavy_paths: List[DropHeavyPathEvidence] = Field(default_factory=list)
|
||||
notes: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"Anomaly views are heuristic and intended as leads for investigation, not final verdicts.",
|
||||
"Scan candidates are sources touching many hosts or ports, beacon candidates are conversations with regular intervals.",
|
||||
"Rare services, reset-heavy paths, and drop-heavy paths help surface unusual or unhealthy communication.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/interface-hosts", response_model=InterfaceHostAnalysisResponse)
|
||||
async def analysis_interface_hosts(
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit_per_interface: int = Query(
|
||||
100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of inferred hosts returned per interface.",
|
||||
),
|
||||
) -> InterfaceHostAnalysisResponse:
|
||||
"""Infer which IP/MAC endpoints are likely attached to each MITM-side interface."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
rows = await db.infer_interface_hosts(since=since, limit_per_interface=limit_per_interface)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to infer interface host mapping: {exc}") from exc
|
||||
|
||||
interfaces = [InterfaceAttachment(**row) for row in rows]
|
||||
return InterfaceHostAnalysisResponse(since=since, interfaces=interfaces)
|
||||
|
||||
|
||||
@router.get("/interface-host-protocols", response_model=InterfaceHostProtocolAnalysisResponse)
|
||||
async def analysis_interface_host_protocols(
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit_per_interface: int = Query(
|
||||
50,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of inferred hosts returned per interface.",
|
||||
),
|
||||
limit_protocols_per_host: int = Query(
|
||||
12,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Maximum number of top protocols returned per inferred host.",
|
||||
),
|
||||
) -> InterfaceHostProtocolAnalysisResponse:
|
||||
"""Infer interface-host attachment and break observed traffic down by protocol."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
rows = await db.infer_interface_host_protocols(
|
||||
since=since,
|
||||
limit_per_interface=limit_per_interface,
|
||||
limit_protocols_per_host=limit_protocols_per_host,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to infer interface host protocol mapping: {exc}") from exc
|
||||
|
||||
interfaces = [InterfaceProtocolAttachment(**row) for row in rows]
|
||||
return InterfaceHostProtocolAnalysisResponse(since=since, interfaces=interfaces)
|
||||
|
||||
|
||||
@router.get("/interface-protocol-paths", response_model=InterfaceProtocolPathAnalysisResponse)
|
||||
async def analysis_interface_protocol_paths(
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit_paths: int = Query(
|
||||
500,
|
||||
ge=1,
|
||||
le=5000,
|
||||
description="Maximum number of grouped packet paths returned for the Sankey view.",
|
||||
),
|
||||
) -> InterfaceProtocolPathAnalysisResponse:
|
||||
"""Aggregate directional packet paths for the Sankey diagram."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
rows = await db.infer_interface_protocol_paths(
|
||||
since=since,
|
||||
limit_paths=limit_paths,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to infer interface protocol paths: {exc}") from exc
|
||||
|
||||
paths = [InterfaceProtocolPathEvidence(**row) for row in rows]
|
||||
return InterfaceProtocolPathAnalysisResponse(since=since, paths=paths)
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=ConversationAnalysisResponse)
|
||||
async def analysis_conversations(
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit: int = Query(
|
||||
300,
|
||||
ge=1,
|
||||
le=5000,
|
||||
description="Maximum number of conversations returned.",
|
||||
),
|
||||
) -> ConversationAnalysisResponse:
|
||||
"""Aggregate directional conversations between observed endpoints."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
rows = await db.analyze_conversations(since=since, limit=limit)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to analyze conversations: {exc}") from exc
|
||||
|
||||
conversations = [ConversationEvidence(**row) for row in rows]
|
||||
return ConversationAnalysisResponse(since=since, conversations=conversations)
|
||||
|
||||
|
||||
@router.get("/conversation-flow-detail", response_model=ConversationFlowDetailResponse)
|
||||
async def analysis_conversation_flow_detail(
|
||||
flow_id: Optional[str] = Query(
|
||||
None,
|
||||
description="Specific flow_id to inspect. If omitted, the directional conversation tuple is used.",
|
||||
),
|
||||
src_ip_address: Optional[str] = Query(None),
|
||||
src_mac_address: Optional[str] = Query(None),
|
||||
dst_ip_address: Optional[str] = Query(None),
|
||||
dst_mac_address: Optional[str] = Query(None),
|
||||
src_port: Optional[int] = Query(None, ge=0, le=65535),
|
||||
dst_port: Optional[int] = Query(None, ge=0, le=65535),
|
||||
protocol: Optional[str] = Query(None),
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit_packets: int = Query(
|
||||
1500,
|
||||
ge=1,
|
||||
le=10000,
|
||||
description="Maximum number of packets returned for this conversation detail view.",
|
||||
),
|
||||
) -> ConversationFlowDetailResponse:
|
||||
"""Return ordered packet detail, subflows, and derived request/response events for one conversation."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
if flow_id is None and all(
|
||||
value is None
|
||||
for value in [src_ip_address, src_mac_address, dst_ip_address, dst_mac_address, src_port, dst_port]
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provide either flow_id or enough directional conversation fields to identify the conversation.",
|
||||
)
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
result = await db.fetch_conversation_flow_detail(
|
||||
flow_id=flow_id,
|
||||
src_ip_address=src_ip_address,
|
||||
src_mac_address=src_mac_address,
|
||||
dst_ip_address=dst_ip_address,
|
||||
dst_mac_address=dst_mac_address,
|
||||
src_port=src_port,
|
||||
dst_port=dst_port,
|
||||
protocol=protocol,
|
||||
since=since,
|
||||
limit_packets=limit_packets,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to analyze conversation flow detail: {exc}") from exc
|
||||
|
||||
return ConversationFlowDetailResponse(since=since, **result)
|
||||
|
||||
|
||||
@router.get("/host-intelligence", response_model=HostIntelligenceAnalysisResponse)
|
||||
async def analysis_host_intelligence(
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit_hosts: int = Query(
|
||||
40,
|
||||
ge=1,
|
||||
le=500,
|
||||
description="Maximum number of hosts returned in the intelligence view.",
|
||||
),
|
||||
) -> HostIntelligenceAnalysisResponse:
|
||||
"""Build host-centric intelligence including peers, services, and hostname hints."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
rows = await db.analyze_host_intelligence(since=since, limit_hosts=limit_hosts)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to analyze host intelligence: {exc}") from exc
|
||||
|
||||
hosts = [HostIntelligenceEvidence(**row) for row in rows]
|
||||
return HostIntelligenceAnalysisResponse(since=since, hosts=hosts)
|
||||
|
||||
|
||||
@router.get("/discovery", response_model=DiscoveryAnalysisResponse)
|
||||
async def analysis_discovery(
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit: int = Query(
|
||||
300,
|
||||
ge=1,
|
||||
le=5000,
|
||||
description="Maximum number of grouped discovery activities returned.",
|
||||
),
|
||||
) -> DiscoveryAnalysisResponse:
|
||||
"""Highlight local discovery, naming, and service advertisement traffic."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
rows = await db.analyze_discovery_activity(since=since, limit=limit)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to analyze discovery activity: {exc}") from exc
|
||||
|
||||
activities = [DiscoveryActivityEvidence(**row) for row in rows]
|
||||
return DiscoveryAnalysisResponse(since=since, activities=activities)
|
||||
|
||||
|
||||
@router.get("/anomalies", response_model=AnomalyAnalysisResponse)
|
||||
async def analysis_anomalies(
|
||||
since_minutes: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
le=60 * 24 * 30,
|
||||
description="Analyze only packets seen within the last N minutes. Omit to cover all captured history.",
|
||||
),
|
||||
limit: int = Query(
|
||||
50,
|
||||
ge=1,
|
||||
le=500,
|
||||
description="Maximum number of anomaly candidates returned per category.",
|
||||
),
|
||||
) -> AnomalyAnalysisResponse:
|
||||
"""Return heuristic anomaly candidates for scans, beaconing, resets, and failures."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
since: Optional[datetime] = None
|
||||
if since_minutes is not None:
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
|
||||
try:
|
||||
result = await db.analyze_anomalies(since=since, limit=limit)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to analyze anomalies: {exc}") from exc
|
||||
|
||||
return AnomalyAnalysisResponse(since=since, **result)
|
||||
@@ -1,123 +1,200 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
"""Network inspection and bridge management endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from pyroute2 import IPRoute, NDB
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
import src.shared_objects as shared
|
||||
from src.config import settings
|
||||
from src.utilities.bridge_link_state_manager import bridge_link_state_manager
|
||||
from src.utilities.interface_bridge_helpers import get_bridge_ports_once, read_interface_ethernet_profile
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("network_router")
|
||||
_ETHTOOL_BIN = "/usr/sbin/ethtool" if os.path.exists("/usr/sbin/ethtool") else "ethtool"
|
||||
DEFAULT_INTERFACE_MTU = 1500
|
||||
|
||||
# Globals for lazy initialization
|
||||
ip: IPRoute | None = None
|
||||
ndb: NDB | None = None
|
||||
|
||||
# ------------------------------
|
||||
# Pydantic models
|
||||
# ------------------------------
|
||||
|
||||
class InterfaceAddress(BaseModel):
|
||||
"""
|
||||
Represents an IP address assigned to a network interface.
|
||||
"""
|
||||
"""IP address assigned to an interface."""
|
||||
|
||||
family: str = Field(..., description="IP family: 'ipv4' or 'ipv6'.")
|
||||
address: str = Field(..., description="The IP address assigned to the interface.")
|
||||
prefixlen: int = Field(..., description="Subnet prefix length (e.g., 24 for 255.255.255.0).")
|
||||
address: str = Field(..., description="IP address.")
|
||||
prefixlen: int = Field(..., description="Subnet prefix length.")
|
||||
|
||||
|
||||
class InterfaceInfo(BaseModel):
|
||||
"""
|
||||
Represents a network interface with all its properties.
|
||||
"""
|
||||
ifindex: int = Field(..., description="Interface index (unique identifier assigned by the kernel).")
|
||||
name: str = Field(..., description="Interface name (e.g., 'eth0', 'enp38s0').")
|
||||
state: str = Field(..., description="Operational state (e.g., 'UP', 'DOWN', 'UNKNOWN').")
|
||||
mac: Optional[str] = Field(None, description="MAC address of the interface, if applicable.")
|
||||
mtu: int = Field(..., description="Maximum Transmission Unit for the interface.")
|
||||
flags: List[str] = Field(..., description="List of interface flags (e.g., ['BROADCAST', 'MULTICAST']).")
|
||||
addresses: List[InterfaceAddress] = Field(..., description="List of IP addresses assigned to the interface.")
|
||||
"""Interface with link metadata and assigned addresses."""
|
||||
|
||||
ifindex: int = Field(..., description="Kernel interface index.")
|
||||
name: str = Field(..., description="Interface name.")
|
||||
state: str = Field(..., description="Operational state.")
|
||||
mac: Optional[str] = Field(None, description="MAC address.")
|
||||
mtu: int = Field(..., description="Maximum transmission unit.")
|
||||
flags: List[str] = Field(..., description="Decoded interface flags.")
|
||||
ethernet_profile: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Current speed/duplex/autoneg profile when available.",
|
||||
)
|
||||
addresses: List[InterfaceAddress] = Field(..., description="Assigned IP addresses.")
|
||||
|
||||
|
||||
class RouteInfo(BaseModel):
|
||||
"""
|
||||
Represents a single routing table entry.
|
||||
"""
|
||||
"""Single routing table entry."""
|
||||
|
||||
dst: Optional[str] = Field(
|
||||
None, description="Destination network in CIDR notation (e.g., '192.168.1.0/24'). None means default route."
|
||||
)
|
||||
dst: Optional[str] = Field(None, description="Destination CIDR; null means default route.")
|
||||
gateway: Optional[str] = Field(None, description="Next-hop gateway.")
|
||||
prefsrc: Optional[str] = Field(None, description="Preferred source IP.")
|
||||
oif: Optional[int] = Field(None, description="Output interface index.")
|
||||
ifname: Optional[str] = Field(None, description="Output interface name.")
|
||||
table: int = Field(..., description="Route table ID.")
|
||||
proto: Optional[int] = Field(None, description="Route protocol code.")
|
||||
scope: Optional[int] = Field(None, description="Route scope code.")
|
||||
type: Optional[int] = Field(None, description="Route type code.")
|
||||
|
||||
gateway: Optional[str] = Field(
|
||||
None, description="Next-hop gateway IP address for this route. None if the route is directly connected."
|
||||
)
|
||||
|
||||
prefsrc: Optional[str] = Field(
|
||||
None, description="Preferred source IP to use when sending packets via this route."
|
||||
)
|
||||
|
||||
oif: Optional[int] = Field(
|
||||
None, description="Output interface index (ifindex) for this route. Can be used to look up the interface name."
|
||||
)
|
||||
|
||||
ifname: Optional[str] = Field(
|
||||
None, description="Name of the interface corresponding to `oif` (e.g., 'eth0')."
|
||||
)
|
||||
|
||||
table: int = Field(
|
||||
..., description="Routing table ID (e.g., 254 = main, 255 = local)."
|
||||
)
|
||||
|
||||
proto: Optional[int] = Field(
|
||||
None,
|
||||
description="Protocol of the route (numeric Linux codes, e.g., 2=kernel, 16=static)."
|
||||
)
|
||||
|
||||
scope: Optional[int] = Field(
|
||||
None,
|
||||
description="Scope of the route: 0=global, 253=link, 254=host, 255=nowhere."
|
||||
)
|
||||
|
||||
type: Optional[int] = Field(
|
||||
None,
|
||||
description="Type of the route (numeric code): 1=unicast, 2=local, 3=broadcast, 5=multicast."
|
||||
)
|
||||
|
||||
class BridgeInterfaceInfo(BaseModel):
|
||||
"""
|
||||
Represents a network interface which is a member of an bridge.
|
||||
"""
|
||||
ifindex: int = Field(..., description="Interface index of a bridge member")
|
||||
ifname: str = Field(..., description="Interface name of a bridge member")
|
||||
state: Optional[str] = Field(None, description="Operational state of the interface")
|
||||
mtu: Optional[int] = Field(None, description="MTU of the interface")
|
||||
"""Interface that belongs to a bridge."""
|
||||
|
||||
ifindex: int = Field(..., description="Interface index.")
|
||||
ifname: str = Field(..., description="Interface name.")
|
||||
state: Optional[str] = Field(None, description="Operational state.")
|
||||
mtu: Optional[int] = Field(None, description="Interface MTU.")
|
||||
ethernet_profile: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Current speed/duplex/autoneg profile when available.",
|
||||
)
|
||||
|
||||
|
||||
class BridgeInfo(BaseModel):
|
||||
"""
|
||||
Represents a network bridge interface with all its properties.
|
||||
"""
|
||||
ifindex: int = Field(..., description="Interface index of the bridge")
|
||||
ifname: str = Field(..., description="Bridge interface name")
|
||||
state: Optional[str] = Field(None, description="Operational state of the bridge")
|
||||
mtu: Optional[int] = Field(None, description="MTU of the bridge")
|
||||
stp_state: Optional[int] = Field(None, description="STP (Spanning Tree Protocol) state of the bridge")
|
||||
members: List[BridgeInterfaceInfo] = Field(default_factory=list, description="List of member interfaces of the bridge")
|
||||
"""Bridge interface with member information."""
|
||||
|
||||
ifindex: int = Field(..., description="Bridge index.")
|
||||
ifname: str = Field(..., description="Bridge name.")
|
||||
state: Optional[str] = Field(None, description="Bridge state.")
|
||||
mtu: Optional[int] = Field(None, description="Bridge MTU.")
|
||||
stp_state: Optional[int] = Field(None, description="Spanning tree state.")
|
||||
members: List[BridgeInterfaceInfo] = Field(default_factory=list, description="Bridge members.")
|
||||
|
||||
|
||||
class BridgeCreateRequest(BaseModel):
|
||||
"""Payload for creating a bridge and attaching interfaces."""
|
||||
|
||||
name: str
|
||||
interfaces: List[str]
|
||||
|
||||
|
||||
class BridgeRemoveRequest(BaseModel):
|
||||
name: str
|
||||
# ------------------------------
|
||||
# Lazy Init Functions
|
||||
# ------------------------------
|
||||
"""Payload for removing a bridge."""
|
||||
|
||||
def init_network_api():
|
||||
name: str
|
||||
|
||||
|
||||
class BridgeLinkStateEnableRequest(BaseModel):
|
||||
"""Payload for enabling bridge member link-state propagation."""
|
||||
|
||||
recovery_holdoff_seconds: float = Field(
|
||||
settings.bridge_link_state_recovery_holdoff_seconds,
|
||||
gt=0,
|
||||
description="Holdoff before sibling interfaces are restored after recovery.",
|
||||
)
|
||||
|
||||
|
||||
class InterfaceResetDefaultsRequest(BaseModel):
|
||||
"""Payload for resetting one or more interfaces to baseline settings."""
|
||||
|
||||
interfaces: List[str] = Field(..., min_length=1, description="Interface names to reset.")
|
||||
|
||||
|
||||
class InterfaceResetDefaultsResult(BaseModel):
|
||||
"""Outcome for one interface reset attempt."""
|
||||
|
||||
interface: str = Field(..., description="Interface name.")
|
||||
mtu: Optional[int] = Field(None, description="Resulting MTU after reset.")
|
||||
ethernet_profile: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Resulting speed/duplex/autoneg profile when available.",
|
||||
)
|
||||
message: str = Field(..., description="Human-readable reset result.")
|
||||
|
||||
|
||||
class InterfaceResetDefaultsResponse(BaseModel):
|
||||
"""Batch reset response for one or more interfaces."""
|
||||
|
||||
results: List[InterfaceResetDefaultsResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BridgeMemberLinkStateInfo(BaseModel):
|
||||
"""Current link-state snapshot for one bridge member."""
|
||||
|
||||
ifname: str = Field(..., description="Interface name.")
|
||||
admin_up: Optional[bool] = Field(None, description="Whether the interface has IFF_UP set.")
|
||||
carrier_up: Optional[bool] = Field(None, description="Whether the interface currently reports carrier.")
|
||||
operstate: Optional[str] = Field(None, description="Kernel operational state string.")
|
||||
mtu: Optional[int] = Field(None, description="Current interface MTU.")
|
||||
ethernet_profile: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Current speed/duplex/autoneg profile when available.",
|
||||
)
|
||||
link_ready: bool = Field(..., description="Whether the member currently looks usable for forwarding.")
|
||||
suppressed: bool = Field(..., description="Whether the watcher administratively suppressed this member.")
|
||||
|
||||
|
||||
class BridgeLinkStateWatcherStatus(BaseModel):
|
||||
"""Status for one bridge link-state propagation watcher."""
|
||||
|
||||
bridge: str = Field(..., description="Bridge interface name.")
|
||||
active: bool = Field(..., description="Whether the watcher thread is currently active.")
|
||||
event_driven: Optional[bool] = Field(None, description="Whether the watcher is driven by netlink link events.")
|
||||
last_event_ts: Optional[float] = Field(None, description="Unix timestamp of the last processed event/state evaluation.")
|
||||
last_error: Optional[str] = Field(None, description="Most recent watcher error, if any.")
|
||||
last_action: Optional[str] = Field(None, description="Most recent propagation action.")
|
||||
suppressed_members: List[str] = Field(default_factory=list, description="Members currently forced down by the watcher.")
|
||||
recovery_holdoff_seconds: Optional[float] = Field(None, description="Configured recovery holdoff before restoring siblings.")
|
||||
degraded_recheck_seconds: Optional[float] = Field(
|
||||
None,
|
||||
description="Low-rate fallback recheck interval while the bridge is degraded.",
|
||||
)
|
||||
failure_holdoff_seconds: Optional[float] = Field(
|
||||
None,
|
||||
description="Transient failure debounce before suppressing siblings.",
|
||||
)
|
||||
members: Dict[str, BridgeMemberLinkStateInfo] = Field(default_factory=dict, description="Per-member link-state snapshot.")
|
||||
message: Optional[str] = Field(None, description="Optional informational message.")
|
||||
|
||||
|
||||
class FullStateResponse(BaseModel):
|
||||
"""Interfaces, routes, bridges, and active watcher state."""
|
||||
|
||||
interfaces: List[InterfaceInfo] = Field(default_factory=list)
|
||||
routes: List[RouteInfo] = Field(default_factory=list)
|
||||
bridges: List[BridgeInfo] = Field(default_factory=list)
|
||||
watchers: List[BridgeLinkStateWatcherStatus] = Field(default_factory=list)
|
||||
|
||||
|
||||
def init_network_api() -> None:
|
||||
"""Initialize lazy pyroute2 clients."""
|
||||
global ip, ndb
|
||||
if ip is None:
|
||||
ip = IPRoute()
|
||||
if ndb is None:
|
||||
ndb = NDB()
|
||||
|
||||
def shutdown_network_api():
|
||||
|
||||
def shutdown_network_api() -> None:
|
||||
"""Close pyroute2 clients if they were initialized."""
|
||||
global ip, ndb
|
||||
bridge_link_state_manager.stop()
|
||||
if ip:
|
||||
ip.close()
|
||||
ip = None
|
||||
@@ -125,37 +202,38 @@ def shutdown_network_api():
|
||||
ndb.close()
|
||||
ndb = None
|
||||
|
||||
def get_iproute():
|
||||
|
||||
def get_iproute() -> IPRoute:
|
||||
"""Dependency provider for the shared IPRoute instance."""
|
||||
if ip is None:
|
||||
init_network_api()
|
||||
return ip
|
||||
|
||||
def get_ndb():
|
||||
|
||||
def get_ndb() -> NDB:
|
||||
"""Dependency provider for the shared NDB instance."""
|
||||
if ndb is None:
|
||||
init_network_api()
|
||||
return ndb
|
||||
|
||||
# ------------------------------
|
||||
# Utility functions
|
||||
# ------------------------------
|
||||
|
||||
def parse_addresses(addrs):
|
||||
res = []
|
||||
for a in addrs:
|
||||
family = "ipv4" if a.get("family") == 2 else "ipv6"
|
||||
res.append(
|
||||
def parse_addresses(addrs: list[dict]) -> list[InterfaceAddress]:
|
||||
"""Convert pyroute2 address rows into `InterfaceAddress` models."""
|
||||
result: list[InterfaceAddress] = []
|
||||
for addr in addrs:
|
||||
family = "ipv4" if addr.get("family") == 2 else "ipv6"
|
||||
result.append(
|
||||
InterfaceAddress(
|
||||
family=family,
|
||||
address=a.get("address"),
|
||||
prefixlen=a.get("prefixlen"),
|
||||
address=addr.get("address"),
|
||||
prefixlen=addr.get("prefixlen"),
|
||||
)
|
||||
)
|
||||
return res
|
||||
return result
|
||||
|
||||
|
||||
def parse_flags(flags_int: int) -> list[str]:
|
||||
"""
|
||||
Converts the integer flags from pyroute2 to human-readable list of strings.
|
||||
"""
|
||||
"""Decode Linux interface flag bitset to names."""
|
||||
flags_map = {
|
||||
0x1: "UP",
|
||||
0x2: "BROADCAST",
|
||||
@@ -177,37 +255,104 @@ def parse_flags(flags_int: int) -> list[str]:
|
||||
0x20000: "DORMANT",
|
||||
0x40000: "ECHO",
|
||||
}
|
||||
result = []
|
||||
for bit, name in flags_map.items():
|
||||
if flags_int & bit:
|
||||
result.append(name)
|
||||
return result
|
||||
return [name for bit, name in flags_map.items() if flags_int & bit]
|
||||
|
||||
def iface_index(name: str, ip: IPRoute) -> int:
|
||||
idx = ip.link_lookup(ifname=name)
|
||||
|
||||
def iface_index(name: str, ip_route: IPRoute) -> int:
|
||||
"""Return interface index for a given interface name."""
|
||||
idx = ip_route.link_lookup(ifname=name)
|
||||
if not idx:
|
||||
raise HTTPException(status_code=404, detail=f"Interface {name} not found")
|
||||
return idx[0]
|
||||
|
||||
|
||||
def bridge_exists(name: str, ip: IPRoute) -> bool:
|
||||
return bool(ip.link_lookup(ifname=name))
|
||||
def bridge_exists(name: str, ip_route: IPRoute) -> bool:
|
||||
"""Check whether a bridge/device with the given name exists."""
|
||||
return bool(ip_route.link_lookup(ifname=name))
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Endpoints
|
||||
# ------------------------------
|
||||
def _watcher_status_response(payload: dict[str, Any]) -> BridgeLinkStateWatcherStatus:
|
||||
"""Convert an internal watcher status dictionary to the API response model."""
|
||||
return BridgeLinkStateWatcherStatus.model_validate(payload)
|
||||
|
||||
|
||||
def _build_full_state_response(ip_route: Optional[IPRoute] = None) -> FullStateResponse:
|
||||
"""Build the current full network snapshot used by HTTP and websocket consumers."""
|
||||
ip_instance = ip_route or get_iproute()
|
||||
return FullStateResponse(
|
||||
interfaces=get_interfaces(ip_instance),
|
||||
routes=get_routes(ip_instance),
|
||||
bridges=get_bridges(),
|
||||
watchers=list_bridge_link_state_watchers(),
|
||||
)
|
||||
|
||||
|
||||
def build_full_state_payload(ip_route: Optional[IPRoute] = None) -> dict[str, Any]:
|
||||
"""Return the current network snapshot as a JSON-safe dictionary."""
|
||||
return _build_full_state_response(ip_route).model_dump(mode="json")
|
||||
|
||||
|
||||
def publish_network_state_update(reason: str) -> None:
|
||||
"""Publish the latest network snapshot to websocket subscribers."""
|
||||
broadcaster = getattr(shared, "network_broadcaster", None)
|
||||
if broadcaster is None:
|
||||
return
|
||||
|
||||
try:
|
||||
broadcaster.sync_publish(
|
||||
{
|
||||
"type": "network_state",
|
||||
"reason": reason,
|
||||
"snapshot": build_full_state_payload(),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to publish network state update")
|
||||
|
||||
|
||||
def _reset_interface_defaults(ifname: str, ip_route: IPRoute) -> InterfaceResetDefaultsResult:
|
||||
"""Reset one interface to a conservative baseline configuration."""
|
||||
idx = iface_index(ifname, ip_route)
|
||||
messages: list[str] = []
|
||||
|
||||
ip_route.link("set", index=idx, state="down")
|
||||
ip_route.link("set", index=idx, mtu=DEFAULT_INTERFACE_MTU)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[_ETHTOOL_BIN, "-s", ifname, "autoneg", "on"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
messages.append("autoneg on")
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
logger.debug("Failed to reset ethtool defaults on %s: %s", ifname, exc)
|
||||
messages.append("autoneg unchanged")
|
||||
|
||||
ip_route.link("set", index=idx, state="up")
|
||||
messages.append(f"mtu {DEFAULT_INTERFACE_MTU}")
|
||||
messages.append("admin up")
|
||||
|
||||
return InterfaceResetDefaultsResult(
|
||||
interface=ifname,
|
||||
mtu=DEFAULT_INTERFACE_MTU,
|
||||
ethernet_profile=read_interface_ethernet_profile(ifname),
|
||||
message=", ".join(messages),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/interfaces", response_model=List[InterfaceInfo])
|
||||
def get_interfaces(ip: IPRoute = Depends(get_iproute)):
|
||||
result = []
|
||||
def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
|
||||
"""List host interfaces with addresses and decoded flags."""
|
||||
result: list[InterfaceInfo] = []
|
||||
links = ip.get_links()
|
||||
addresses = ip.get_addr()
|
||||
|
||||
addr_map = {}
|
||||
for a in addresses:
|
||||
ifindex = a.get("index")
|
||||
addr_map.setdefault(ifindex, []).append(a)
|
||||
addr_map: dict[int, list] = {}
|
||||
for addr in addresses:
|
||||
ifindex = addr.get("index")
|
||||
addr_map.setdefault(ifindex, []).append(addr)
|
||||
|
||||
for link in links:
|
||||
attrs = dict(link["attrs"])
|
||||
@@ -222,60 +367,59 @@ def get_interfaces(ip: IPRoute = Depends(get_iproute)):
|
||||
mac=attrs.get("IFLA_ADDRESS"),
|
||||
mtu=attrs.get("IFLA_MTU"),
|
||||
flags=parse_flags(link.get("flags", 0)),
|
||||
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME")),
|
||||
addresses=parse_addresses(addrs),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/routes", response_model=List[RouteInfo])
|
||||
def get_routes(ip: IPRoute = Depends(get_iproute)):
|
||||
routes = []
|
||||
for r in ip.get_routes():
|
||||
attrs = dict(r["attrs"])
|
||||
def get_routes(ip: IPRoute = Depends(get_iproute)) -> List[RouteInfo]:
|
||||
"""List routes from the kernel routing tables."""
|
||||
routes: list[RouteInfo] = []
|
||||
for route in ip.get_routes():
|
||||
attrs = dict(route["attrs"])
|
||||
dst = attrs.get("RTA_DST")
|
||||
gateway = attrs.get("RTA_GATEWAY")
|
||||
prefsrc = attrs.get("RTA_PREFSRC")
|
||||
oif = r.get("oif")
|
||||
oif = route.get("oif")
|
||||
|
||||
ifname = None
|
||||
if oif is not None:
|
||||
# translate ifindex → name
|
||||
link = ip.get_links(oif)[0]
|
||||
ifname = dict(link["attrs"]).get("IFLA_IFNAME")
|
||||
|
||||
routes.append(
|
||||
RouteInfo(
|
||||
dst=f"{dst}/{r.get('dst_len')}" if dst else None,
|
||||
dst=f"{dst}/{route.get('dst_len')}" if dst else None,
|
||||
gateway=gateway,
|
||||
prefsrc=prefsrc,
|
||||
oif=oif,
|
||||
ifname=ifname,
|
||||
table=r.get("table", 254),
|
||||
proto=r.get("proto"),
|
||||
scope=r.get("scope"),
|
||||
type=r.get("type"),
|
||||
table=route.get("table", 254),
|
||||
proto=route.get("proto"),
|
||||
scope=route.get("scope"),
|
||||
type=route.get("type"),
|
||||
)
|
||||
)
|
||||
return routes
|
||||
|
||||
|
||||
@router.get("/links", response_model=List[InterfaceInfo])
|
||||
def get_raw_links(ip: IPRoute = Depends(get_iproute)):
|
||||
"""
|
||||
Returns all interfaces in a clean Pydantic format.
|
||||
This is similar to /interfaces but avoids additional processing if needed.
|
||||
"""
|
||||
result = []
|
||||
def get_raw_links(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
|
||||
"""List links in a normalized structure for UI consumers."""
|
||||
result: list[InterfaceInfo] = []
|
||||
links = ip.get_links()
|
||||
addresses = ip.get_addr()
|
||||
|
||||
# group addresses by interface index
|
||||
addr_map = {}
|
||||
for a in addresses:
|
||||
ifindex = a.get("index")
|
||||
addr_map.setdefault(ifindex, []).append(a)
|
||||
addr_map: dict[int, list] = {}
|
||||
for addr in addresses:
|
||||
ifindex = addr.get("index")
|
||||
addr_map.setdefault(ifindex, []).append(addr)
|
||||
|
||||
for link in links:
|
||||
attrs = dict(link.get("attrs", [])) # convert list of tuples to dict
|
||||
attrs = dict(link.get("attrs", []))
|
||||
ifindex = link["index"]
|
||||
addrs = addr_map.get(ifindex, [])
|
||||
|
||||
@@ -286,116 +430,238 @@ def get_raw_links(ip: IPRoute = Depends(get_iproute)):
|
||||
state=attrs.get("IFLA_OPERSTATE", "unknown"),
|
||||
mac=attrs.get("IFLA_ADDRESS"),
|
||||
mtu=attrs.get("IFLA_MTU", 0),
|
||||
flags=[], # latest pyroute2 removed ifi_flags, leave empty
|
||||
flags=[],
|
||||
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME", "unknown")),
|
||||
addresses=parse_addresses(addrs),
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/bridges", response_model=List[BridgeInfo])
|
||||
def get_bridges():
|
||||
"""
|
||||
Get all bridge interfaces on the system, including their member interfaces.
|
||||
Returns detailed information:
|
||||
- Bridge index, name, state, MTU
|
||||
- STP state
|
||||
- Member interfaces with index, name, state, and MTU
|
||||
"""
|
||||
bridges_list: List[BridgeInfo] = []
|
||||
|
||||
with NDB() as ndb:
|
||||
for br in ndb.interfaces:
|
||||
# Only bridges
|
||||
if getattr(br, "kind", None) == "bridge":
|
||||
members: List[BridgeInterfaceInfo] = []
|
||||
# Find member interfaces
|
||||
for iface in ndb.interfaces:
|
||||
if getattr(iface, "master", None) == br.index:
|
||||
@router.get("/bridges", response_model=List[BridgeInfo])
|
||||
def get_bridges() -> List[BridgeInfo]:
|
||||
"""List all bridges and their current member interfaces."""
|
||||
bridges_list: list[BridgeInfo] = []
|
||||
|
||||
with NDB() as ndb_ctx:
|
||||
for bridge in ndb_ctx.interfaces:
|
||||
if getattr(bridge, "kind", None) != "bridge":
|
||||
continue
|
||||
|
||||
members: list[BridgeInterfaceInfo] = []
|
||||
for iface in ndb_ctx.interfaces:
|
||||
if getattr(iface, "master", None) == bridge.index:
|
||||
members.append(
|
||||
BridgeInterfaceInfo(
|
||||
ifindex=iface.index,
|
||||
ifname=iface.ifname,
|
||||
state=getattr(iface, "operstate", None),
|
||||
mtu=getattr(iface, "mtu", None)
|
||||
mtu=getattr(iface, "mtu", None),
|
||||
ethernet_profile=read_interface_ethernet_profile(iface.ifname),
|
||||
)
|
||||
)
|
||||
|
||||
bridges_list.append(
|
||||
BridgeInfo(
|
||||
ifindex=br.index,
|
||||
ifname=br.ifname,
|
||||
state=getattr(br, "operstate", None),
|
||||
mtu=getattr(br, "mtu", None),
|
||||
stp_state=getattr(br, "stp_state", None),
|
||||
members=members
|
||||
ifindex=bridge.index,
|
||||
ifname=bridge.ifname,
|
||||
state=getattr(bridge, "operstate", None),
|
||||
mtu=getattr(bridge, "mtu", None),
|
||||
stp_state=getattr(bridge, "stp_state", None),
|
||||
members=members,
|
||||
)
|
||||
)
|
||||
|
||||
return bridges_list
|
||||
|
||||
@router.get("/full-state")
|
||||
def full_state(
|
||||
|
||||
@router.get("/full-state", response_model=FullStateResponse)
|
||||
def full_state(ip: IPRoute = Depends(get_iproute)) -> FullStateResponse:
|
||||
"""Return interfaces, routes, and bridges in one response."""
|
||||
return _build_full_state_response(ip)
|
||||
|
||||
|
||||
@router.post("/interfaces/reset-defaults", response_model=InterfaceResetDefaultsResponse)
|
||||
def reset_interfaces_to_defaults(
|
||||
req: InterfaceResetDefaultsRequest,
|
||||
ip: IPRoute = Depends(get_iproute),
|
||||
):
|
||||
"""
|
||||
Returns the full network state:
|
||||
- Interfaces with IP addresses and flags
|
||||
- Routes
|
||||
- Bridges with member interfaces
|
||||
"""
|
||||
return {
|
||||
"interfaces": get_interfaces(ip),
|
||||
"routes": get_routes(ip),
|
||||
"bridges": get_bridges(), # uses NDB internally
|
||||
}
|
||||
) -> InterfaceResetDefaultsResponse:
|
||||
"""Reset listed interfaces to baseline MTU/autoneg/up settings."""
|
||||
unique_ifaces = list(dict.fromkeys(req.interfaces))
|
||||
results = [_reset_interface_defaults(ifname, ip) for ifname in unique_ifaces]
|
||||
publish_network_state_update("interfaces_reset_defaults")
|
||||
return InterfaceResetDefaultsResponse(results=results)
|
||||
|
||||
|
||||
@router.post("/bridge/create")
|
||||
def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute)):
|
||||
def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute)) -> dict:
|
||||
"""Create a bridge and attach listed interfaces."""
|
||||
if bridge_exists(req.name, ip):
|
||||
raise HTTPException(400, detail=f"Bridge {req.name} already exists")
|
||||
raise HTTPException(status_code=400, detail=f"Bridge {req.name} already exists")
|
||||
|
||||
# Bridge erzeugen
|
||||
ip.link("add", ifname=req.name, kind="bridge")
|
||||
br_idx = iface_index(req.name, ip)
|
||||
|
||||
# Bridge konfigurieren
|
||||
# TODO Parameter anpassen (STP, etc.)
|
||||
ip.link("set", index=br_idx, kind="bridge", br_stp_state=0)
|
||||
ip.link("set", index=br_idx, state="up")
|
||||
|
||||
# Interfaces hinzufügen + aktivieren
|
||||
for iface in req.interfaces:
|
||||
idx = iface_index(iface, ip)
|
||||
|
||||
# interface hochfahren
|
||||
ip.link("set", index=idx, state="down") # optional - sicherer
|
||||
ip.link("set", index=idx, state="down")
|
||||
ip.link("set", index=idx, state="up")
|
||||
|
||||
# interface in die bridge hängen
|
||||
ip.link("set", index=idx, master=br_idx)
|
||||
|
||||
publish_network_state_update("bridge_created")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"bridge": req.name,
|
||||
"interfaces": req.interfaces
|
||||
"interfaces": req.interfaces,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bridge/remove")
|
||||
def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute)):
|
||||
def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute)) -> dict:
|
||||
"""Detach and remove a bridge by name."""
|
||||
if not bridge_exists(req.name, ip):
|
||||
raise HTTPException(404, f"Bridge {req.name} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Bridge {req.name} not found")
|
||||
|
||||
br_idx = iface_index(req.name, ip)
|
||||
|
||||
# Bridge runterfahren
|
||||
ip.link("set", index=br_idx, state="down")
|
||||
|
||||
# Bridge löschen
|
||||
ip.link("del", index=br_idx)
|
||||
|
||||
publish_network_state_update("bridge_removed")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"deleted": req.name
|
||||
"deleted": req.name,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/bridge/link-state-watchers", response_model=List[BridgeLinkStateWatcherStatus])
|
||||
def list_bridge_link_state_watchers() -> List[BridgeLinkStateWatcherStatus]:
|
||||
"""List all bridge member link-state propagation watchers."""
|
||||
return [_watcher_status_response(status) for status in bridge_link_state_manager.list_statuses()]
|
||||
|
||||
|
||||
@router.get("/bridge/{bridge_name}/link-state-watcher", response_model=BridgeLinkStateWatcherStatus)
|
||||
def get_bridge_link_state_watcher(
|
||||
bridge_name: str,
|
||||
ip: IPRoute = Depends(get_iproute),
|
||||
) -> BridgeLinkStateWatcherStatus:
|
||||
"""Return the watcher status for one bridge."""
|
||||
if not bridge_exists(bridge_name, ip):
|
||||
raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found")
|
||||
|
||||
status = bridge_link_state_manager.get_status(bridge_name)
|
||||
if status is None:
|
||||
return BridgeLinkStateWatcherStatus(
|
||||
bridge=bridge_name,
|
||||
active=False,
|
||||
message="watcher not enabled",
|
||||
)
|
||||
return _watcher_status_response(status)
|
||||
|
||||
|
||||
@router.post("/bridge/{bridge_name}/link-state-watcher/enable", response_model=BridgeLinkStateWatcherStatus)
|
||||
def enable_bridge_link_state_watcher(
|
||||
bridge_name: str,
|
||||
req: BridgeLinkStateEnableRequest,
|
||||
ip: IPRoute = Depends(get_iproute),
|
||||
) -> BridgeLinkStateWatcherStatus:
|
||||
"""Enable member link-state propagation for a bridge."""
|
||||
if not bridge_exists(bridge_name, ip):
|
||||
raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found")
|
||||
|
||||
members = get_bridge_ports_once(bridge_name)
|
||||
if len(members) < 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bridge {bridge_name} must have at least two member interfaces",
|
||||
)
|
||||
|
||||
status = bridge_link_state_manager.enable(
|
||||
bridge_name=bridge_name,
|
||||
recovery_holdoff_seconds=req.recovery_holdoff_seconds,
|
||||
)
|
||||
publish_network_state_update("bridge_watcher_enabled")
|
||||
return _watcher_status_response(status)
|
||||
|
||||
|
||||
@router.post("/bridge/{bridge_name}/link-state-watcher/disable", response_model=BridgeLinkStateWatcherStatus)
|
||||
def disable_bridge_link_state_watcher(
|
||||
bridge_name: str,
|
||||
ip: IPRoute = Depends(get_iproute),
|
||||
) -> BridgeLinkStateWatcherStatus:
|
||||
"""Disable member link-state propagation for a bridge."""
|
||||
if not bridge_exists(bridge_name, ip):
|
||||
raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found")
|
||||
|
||||
status = _watcher_status_response(bridge_link_state_manager.disable(bridge_name))
|
||||
publish_network_state_update("bridge_watcher_disabled")
|
||||
return status
|
||||
|
||||
|
||||
@router.websocket("/ws/state")
|
||||
async def websocket_network_state(ws: WebSocket) -> None:
|
||||
"""Stream full network snapshots to websocket clients whenever the backend publishes updates."""
|
||||
await ws.accept()
|
||||
broadcaster = getattr(shared, "network_broadcaster", None)
|
||||
if broadcaster is None:
|
||||
await ws.send_json({"error": "network broadcaster not available"})
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
queue: Optional[asyncio.Queue] = None
|
||||
try:
|
||||
initial_snapshot = await asyncio.to_thread(build_full_state_payload)
|
||||
await ws.send_json({"type": "network_state", "reason": "initial", "snapshot": initial_snapshot})
|
||||
queue = await broadcaster.subscribe()
|
||||
|
||||
while True:
|
||||
queue_task = asyncio.create_task(queue.get())
|
||||
receive_task = asyncio.create_task(ws.receive())
|
||||
done, pending = await asyncio.wait({queue_task, receive_task}, return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
if receive_task in done:
|
||||
try:
|
||||
inbound = receive_task.result()
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
|
||||
if inbound.get("type") == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if queue_task not in done:
|
||||
if ws.client_state is not WebSocketState.CONNECTED:
|
||||
break
|
||||
continue
|
||||
|
||||
message = queue_task.result()
|
||||
if isinstance(message, dict) and message.get("type") == "__broadcaster_shutdown__":
|
||||
break
|
||||
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if queue is not None:
|
||||
try:
|
||||
await broadcaster.unsubscribe(queue)
|
||||
except Exception:
|
||||
logger.exception("Failed to unsubscribe network websocket queue")
|
||||
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,49 +1,73 @@
|
||||
# src/routers/packets.py
|
||||
"""Packet history and streaming endpoints."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
import src.shared_objects as shared
|
||||
from src.Models.packets import PacketDBModel
|
||||
|
||||
logger = logging.getLogger("packets_router")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_row_for_json(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert DB row / pkt_info to a JSON-serializable dict.
|
||||
- If 'raw' is bytes, produce 'raw_b64' and drop 'raw'.
|
||||
- Fallback to str() for unknown/unserializable values.
|
||||
"""
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in row.items():
|
||||
if k == "raw" and isinstance(v, (bytes, bytearray)):
|
||||
out["raw_b64"] = base64.b64encode(v).decode("ascii")
|
||||
continue
|
||||
# try to JSON serialize the value directly
|
||||
def _serialize_row_for_json(row: Union[Dict[str, Any], PacketDBModel, BaseModel]) -> Dict[str, Any]:
|
||||
"""Convert one packet row to a JSON-safe dictionary."""
|
||||
if isinstance(row, BaseModel):
|
||||
raw_dict: Dict[str, Any] = row.dict(by_alias=True, exclude_none=True)
|
||||
else:
|
||||
raw_dict = dict(row)
|
||||
|
||||
raw_val = raw_dict.get("raw")
|
||||
if raw_val is not None and isinstance(raw_val, (bytes, bytearray)):
|
||||
try:
|
||||
json.dumps({k: v})
|
||||
out[k] = v
|
||||
raw_dict["raw_b64"] = base64.b64encode(raw_val).decode("ascii")
|
||||
raw_dict.pop("raw", None)
|
||||
except Exception:
|
||||
try:
|
||||
raw_dict["raw_b64"] = base64.b64encode(bytes(raw_val)).decode("ascii")
|
||||
raw_dict.pop("raw", None)
|
||||
except Exception:
|
||||
logger.exception("Failed to base64-encode raw bytes for row id=%s", raw_dict.get("id"))
|
||||
raw_dict["raw_b64"] = str(raw_val)
|
||||
raw_dict.pop("raw", None)
|
||||
|
||||
output: Dict[str, Any] = {}
|
||||
for key, value in raw_dict.items():
|
||||
if key == "raw_b64" and isinstance(value, (bytes, bytearray)):
|
||||
try:
|
||||
output["raw_b64"] = base64.b64encode(value).decode("ascii")
|
||||
except Exception:
|
||||
output["raw_b64"] = str(value)
|
||||
continue
|
||||
|
||||
try:
|
||||
json.dumps({key: value})
|
||||
output[key] = value
|
||||
except (TypeError, ValueError):
|
||||
out[k] = str(v)
|
||||
return out
|
||||
try:
|
||||
output[key] = str(value)
|
||||
except Exception:
|
||||
output[key] = "<unserializable>"
|
||||
|
||||
return output
|
||||
|
||||
|
||||
async def _serialize_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return [_serialize_row_for_json(r) for r in rows]
|
||||
async def _serialize_rows(rows: List[Union[Dict[str, Any], PacketDBModel]]) -> List[Dict[str, Any]]:
|
||||
"""Convert packet rows to JSON-safe dictionaries, preserving order."""
|
||||
return [_serialize_row_for_json(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/packets")
|
||||
async def get_packets(limit: int = Query(100, ge=1, le=10000)):
|
||||
"""
|
||||
Return latest `limit` packets (newest first). The DB helper already converts
|
||||
`raw` to `raw_b64` in fetch_latest, but we defensively re-serialize here.
|
||||
"""
|
||||
async def get_packets(limit: int = Query(100, ge=1, le=10000)) -> JSONResponse:
|
||||
"""Return the latest packets in reverse chronological order."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
logger.warning("GET /packets called but DB is not available")
|
||||
@@ -51,21 +75,16 @@ async def get_packets(limit: int = Query(100, ge=1, le=10000)):
|
||||
|
||||
try:
|
||||
rows = await db.fetch_latest(limit)
|
||||
serial = await _serialize_rows(rows)
|
||||
return JSONResponse(content={"count": len(serial), "packets": serial})
|
||||
except Exception:
|
||||
serialized = await _serialize_rows(rows)
|
||||
return JSONResponse(content={"count": len(serialized), "packets": serialized})
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to fetch latest packets from DB")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch packets")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch packets") from exc
|
||||
|
||||
|
||||
@router.websocket("/ws/packets")
|
||||
async def websocket_packets(ws: WebSocket):
|
||||
"""
|
||||
WebSocket live feed endpoint.
|
||||
|
||||
Accepts optional query param `subscribe_recent` (e.g. ?subscribe_recent=20)
|
||||
which will deliver the last N packets immediately on connect.
|
||||
"""
|
||||
async def websocket_packets(ws: WebSocket) -> None:
|
||||
"""Stream live packets to a websocket client."""
|
||||
await ws.accept()
|
||||
logger.debug("WebSocket connection accepted: %s", ws.client)
|
||||
|
||||
@@ -84,61 +103,109 @@ async def websocket_packets(ws: WebSocket):
|
||||
logger.warning("WebSocket closed: broadcaster not available")
|
||||
return
|
||||
|
||||
# Parse subscribe_recent from query params (defensive)
|
||||
try:
|
||||
subscribe_recent_raw = ws.query_params.get("subscribe_recent", "0")
|
||||
subscribe_recent = int(subscribe_recent_raw)
|
||||
if subscribe_recent < 0:
|
||||
subscribe_recent = 0
|
||||
subscribe_recent = max(subscribe_recent, 0)
|
||||
except Exception:
|
||||
subscribe_recent = 0
|
||||
|
||||
q: Optional[asyncio.Queue] = None
|
||||
queue: Optional[asyncio.Queue] = None
|
||||
try:
|
||||
# Optionally send recent history first
|
||||
if subscribe_recent > 0:
|
||||
recent = await db.fetch_latest(subscribe_recent)
|
||||
recent_serial = await _serialize_rows(recent)
|
||||
await ws.send_json({"type": "recent", "count": len(recent_serial), "packets": recent_serial})
|
||||
recent_serialized = await _serialize_rows(recent)
|
||||
await ws.send_json({"type": "recent", "count": len(recent_serialized), "packets": recent_serialized})
|
||||
|
||||
# Subscribe to broadcaster to receive live packets
|
||||
q = await broadcaster.subscribe()
|
||||
logger.info("WebSocket subscribed client %s (queue maxsize=%d)", ws.client, q.maxsize)
|
||||
queue = await broadcaster.subscribe()
|
||||
logger.info("WebSocket subscribed client %s (queue maxsize=%d)", ws.client, queue.maxsize)
|
||||
|
||||
# Simple heartbeat: periodically ensure client is responsive (optional)
|
||||
# We'll implement by awaiting q.get() which blocks until a message is published.
|
||||
while True:
|
||||
msg = await q.get()
|
||||
# Normalize message to JSON-able dict
|
||||
if isinstance(msg, dict):
|
||||
payload = _serialize_row_for_json(msg)
|
||||
else:
|
||||
# not a dict — try to json-serialize directly
|
||||
queue_task = asyncio.create_task(queue.get())
|
||||
receive_task = asyncio.create_task(ws.receive())
|
||||
done, pending = await asyncio.wait(
|
||||
{queue_task, receive_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
if receive_task in done:
|
||||
try:
|
||||
json.dumps(msg)
|
||||
payload = msg
|
||||
inbound = receive_task.result()
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket client disconnected: %s", ws.client)
|
||||
break
|
||||
except Exception:
|
||||
payload = {"data": str(msg)}
|
||||
logger.info("WebSocket receive failed for client %s; unsubscribing", ws.client)
|
||||
break
|
||||
|
||||
if inbound.get("type") == "websocket.disconnect":
|
||||
logger.info("WebSocket disconnect received for client %s", ws.client)
|
||||
break
|
||||
|
||||
if queue_task not in done:
|
||||
if ws.client_state is not WebSocketState.CONNECTED:
|
||||
break
|
||||
continue
|
||||
|
||||
message = queue_task.result()
|
||||
if isinstance(message, dict) and message.get("type") == "__broadcaster_shutdown__":
|
||||
logger.info("Broadcaster shutdown delivered to client %s", ws.client)
|
||||
break
|
||||
|
||||
if isinstance(message, dict):
|
||||
payload: Any = _serialize_row_for_json(message)
|
||||
else:
|
||||
try:
|
||||
json.dumps(message)
|
||||
payload = message
|
||||
except Exception:
|
||||
payload = {"data": str(message)}
|
||||
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception:
|
||||
# sending failed (client disconnected or write error)
|
||||
logger.info("WebSocket send failed for client %s — unsubscribing", ws.client)
|
||||
logger.info("WebSocket send failed for client %s; unsubscribing", ws.client)
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket client disconnected: %s", ws.client)
|
||||
except Exception:
|
||||
logger.exception("Unexpected error in websocket_packets")
|
||||
finally:
|
||||
# Clean up subscriber queue
|
||||
if q is not None:
|
||||
if queue is not None:
|
||||
try:
|
||||
await broadcaster.unsubscribe(q)
|
||||
await broadcaster.unsubscribe(queue)
|
||||
except Exception:
|
||||
logger.exception("Failed to unsubscribe websocket queue")
|
||||
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("WebSocket connection closed and cleaned up for client %s", ws.client)
|
||||
|
||||
logger.debug("WebSocket connection cleaned up for client %s", ws.client)
|
||||
|
||||
|
||||
@router.delete("/packets")
|
||||
async def clear_packets(reset_id: bool = Query(True)) -> JSONResponse:
|
||||
"""Remove all packet rows from the database."""
|
||||
db = shared.db
|
||||
if db is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
success = await db.clear_all_packets(reset_identity=reset_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to clear packet table")
|
||||
|
||||
logger.info("User initiated clear_packets (reset_id=%s)", reset_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"message": "All packets have been cleared",
|
||||
"reset_id": reset_id,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,36 +2,42 @@
|
||||
NFQUEUE Python-Scripting API Router
|
||||
Endpoints:
|
||||
- POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field
|
||||
- GET /scripts -> list scripts
|
||||
- GET /scripts/{name} -> download script
|
||||
- GET /scripts -> list scripts + per-script unit mappings/status (combined)
|
||||
- GET /scripts/{name} -> download script (binary blob)
|
||||
- GET /scripts/{name}/requirements -> download requirements file (binary blob) if present
|
||||
- PUT /scripts/{name}/requirements -> upload/replace requirements file (multipart). ALWAYS runs pip install and returns pip output.
|
||||
- DELETE /scripts/{name}/requirements -> delete only requirements file and remove venv (cleanup)
|
||||
- POST /scripts/{name}/enable -> enable systemd service for script on given qnum
|
||||
- POST /scripts/{name}/disable -> disable service for script on qnum
|
||||
- DELETE /scripts/{name} -> disable all or a specific qnum service(s) and delete script + venv + requirements
|
||||
- GET /scripts/status -> status of all fw-script units
|
||||
- GET /scripts/{name}/status -> status of units for that script
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, List, Dict
|
||||
from typing import Optional, List, Dict, Any, Tuple, DefaultDict
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------- Configuration ----------
|
||||
SCRIPT_DIR = "/srv/fw-scripts"
|
||||
REPO_BACKEND_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
EXAMPLE_SCRIPT_DIR = os.path.join(REPO_BACKEND_DIR, "example_scripts")
|
||||
VENV_BASE = "/srv/fw-scripts/venvs"
|
||||
UNIT_DIR = "/etc/systemd/system" # retained for writing new units, but discovery uses systemctl
|
||||
UNIT_PREFIX = "fw-script"
|
||||
|
||||
# ensure dirs exist
|
||||
os.makedirs(SCRIPT_DIR, exist_ok=True)
|
||||
os.makedirs(EXAMPLE_SCRIPT_DIR, exist_ok=True)
|
||||
os.makedirs(VENV_BASE, exist_ok=True)
|
||||
|
||||
# ---------- Logging ----------
|
||||
@@ -44,6 +50,7 @@ router = APIRouter(prefix="/scripts", tags=["scripts"])
|
||||
# ---------- Name validation ----------
|
||||
_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
|
||||
|
||||
|
||||
def validate_name(name: str) -> None:
|
||||
if not name:
|
||||
raise ValueError("name must be provided")
|
||||
@@ -52,35 +59,171 @@ def validate_name(name: str) -> None:
|
||||
if name in (".", ".."):
|
||||
raise ValueError("invalid name")
|
||||
|
||||
|
||||
# ---------- Paths ----------
|
||||
def script_path_for(name: str) -> str:
|
||||
return os.path.join(SCRIPT_DIR, f"{name}.py")
|
||||
|
||||
|
||||
def requirements_path_for(name: str) -> str:
|
||||
return os.path.join(SCRIPT_DIR, f"{name}-requirements.txt")
|
||||
|
||||
|
||||
def example_script_path_for(name: str) -> str:
|
||||
return os.path.join(EXAMPLE_SCRIPT_DIR, f"{name}.py")
|
||||
|
||||
|
||||
def example_requirements_path_for(name: str) -> str:
|
||||
return os.path.join(EXAMPLE_SCRIPT_DIR, f"{name}-requirements.txt")
|
||||
|
||||
|
||||
def example_deploy_config_path_for(name: str) -> str:
|
||||
return os.path.join(EXAMPLE_SCRIPT_DIR, f"{name}.deploy.json")
|
||||
|
||||
|
||||
def venv_path_for(name: str) -> str:
|
||||
return os.path.join(VENV_BASE, name)
|
||||
|
||||
|
||||
def venv_python_for(name: str) -> str:
|
||||
vpy = os.path.join(venv_path_for(name), "bin", "python")
|
||||
if os.path.exists(vpy):
|
||||
return vpy
|
||||
return "/usr/bin/python3"
|
||||
|
||||
|
||||
def make_service_name(name: str, qnum: int) -> str:
|
||||
# safe, deterministic service name
|
||||
return f"{UNIT_PREFIX}-{name}-q{qnum}"
|
||||
|
||||
|
||||
def unit_path_for_name(service_name: str) -> str:
|
||||
# default location for units we write
|
||||
return os.path.join(UNIT_DIR, service_name + ".service")
|
||||
|
||||
|
||||
def is_example_script(name: str) -> bool:
|
||||
return os.path.exists(example_script_path_for(name))
|
||||
|
||||
|
||||
def assert_not_example_script(name: str, action: str) -> None:
|
||||
if is_example_script(name):
|
||||
raise HTTPException(status_code=403, detail=f"example script '{name}' is protected and cannot be {action} via API")
|
||||
|
||||
|
||||
def _read_example_deploy_config(name: str) -> Dict[str, Any]:
|
||||
cfg_path = example_deploy_config_path_for(name)
|
||||
if not os.path.exists(cfg_path):
|
||||
return {}
|
||||
try:
|
||||
with open(cfg_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Deploy config for %s is not an object: %s", name, cfg_path)
|
||||
return {}
|
||||
return data
|
||||
except Exception:
|
||||
logger.exception("Failed reading deploy config for %s at %s", name, cfg_path)
|
||||
return {}
|
||||
|
||||
|
||||
def deploy_example_scripts_on_startup() -> None:
|
||||
"""
|
||||
Sync example scripts from EXAMPLE_SCRIPT_DIR into SCRIPT_DIR and auto-deploy units
|
||||
if an optional '<name>.deploy.json' contains a queue number.
|
||||
|
||||
Optional config schema per script:
|
||||
{
|
||||
"qnum": 42,
|
||||
"extra_args": "--flag value",
|
||||
"enable_at_boot": true,
|
||||
"service_name": "fw-script-<name>-q<qnum>"
|
||||
}
|
||||
service_name is optional and must start with 'fw-script-'.
|
||||
"""
|
||||
if not os.path.isdir(EXAMPLE_SCRIPT_DIR):
|
||||
return
|
||||
|
||||
for fn in os.listdir(EXAMPLE_SCRIPT_DIR):
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
|
||||
name = fn.rsplit(".", 1)[0]
|
||||
src_script = os.path.join(EXAMPLE_SCRIPT_DIR, fn)
|
||||
dst_script = script_path_for(name)
|
||||
|
||||
try:
|
||||
shutil.copy2(src_script, dst_script)
|
||||
os.chmod(dst_script, 0o700)
|
||||
logger.info("Synced example script %s -> %s", src_script, dst_script)
|
||||
except Exception:
|
||||
logger.exception("Failed syncing example script %s", src_script)
|
||||
continue
|
||||
|
||||
src_req = example_requirements_path_for(name)
|
||||
dst_req = requirements_path_for(name)
|
||||
if os.path.exists(src_req):
|
||||
try:
|
||||
shutil.copy2(src_req, dst_req)
|
||||
logger.info("Synced example requirements %s -> %s", src_req, dst_req)
|
||||
pip_install_requirements(name, dst_req)
|
||||
except Exception:
|
||||
logger.exception("Failed syncing/installing requirements for example %s", name)
|
||||
|
||||
cfg = _read_example_deploy_config(name)
|
||||
qnum_raw = cfg.get("qnum")
|
||||
if qnum_raw is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
qnum = int(qnum_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid qnum in deploy config for %s: %r", name, qnum_raw)
|
||||
continue
|
||||
|
||||
if qnum < 0 or qnum > 65535:
|
||||
logger.warning("Out-of-range qnum in deploy config for %s: %d", name, qnum)
|
||||
continue
|
||||
|
||||
default_service_name = make_service_name(name, qnum)
|
||||
configured_service_name = cfg.get("service_name")
|
||||
if configured_service_name:
|
||||
if str(configured_service_name).startswith(UNIT_PREFIX + "-"):
|
||||
service_name = str(configured_service_name)
|
||||
else:
|
||||
logger.warning(
|
||||
"Ignoring invalid service_name for example %s: %r (must start with '%s-'). Falling back to %s",
|
||||
name,
|
||||
configured_service_name,
|
||||
UNIT_PREFIX,
|
||||
default_service_name,
|
||||
)
|
||||
service_name = default_service_name
|
||||
else:
|
||||
service_name = default_service_name
|
||||
extra_args = cfg.get("extra_args") or ""
|
||||
enable_at_boot = bool(cfg.get("enable_at_boot", True))
|
||||
|
||||
python_path = venv_python_for(name)
|
||||
exec_start = f"{python_path} {dst_script} {qnum}"
|
||||
if extra_args:
|
||||
exec_start += " " + str(extra_args)
|
||||
|
||||
try:
|
||||
write_unit(service_name, exec_start, description=f"FW example script {name} queue {qnum}", enable_at_boot=enable_at_boot)
|
||||
time.sleep(0.05)
|
||||
start_unit(service_name)
|
||||
logger.info("Auto-deployed example script %s as %s", name, service_name)
|
||||
except Exception:
|
||||
logger.exception("Failed auto-deploying example script %s", name)
|
||||
|
||||
|
||||
# ---------- systemd interaction (systemctl-based, no fallback) ----------
|
||||
def _systemctl_unit_name(unit: str) -> str:
|
||||
"""Return unit with .service suffix if missing."""
|
||||
return unit if unit.endswith(".service") else unit + ".service"
|
||||
|
||||
|
||||
def list_fw_units() -> List[str]:
|
||||
"""
|
||||
Return list of systemd units (without .service suffix) whose name starts with UNIT_PREFIX-.
|
||||
@@ -88,8 +231,13 @@ def list_fw_units() -> List[str]:
|
||||
"""
|
||||
units: List[str] = []
|
||||
try:
|
||||
p = subprocess.run(["systemctl", "list-units", "--type=service", "--all", "--no-legend"],
|
||||
capture_output=True, text=True, check=False, timeout=3)
|
||||
p = subprocess.run(
|
||||
["systemctl", "list-units", "--type=service", "--all", "--no-legend"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=3,
|
||||
)
|
||||
out = p.stdout or ""
|
||||
# each line starts with unit name
|
||||
for line in out.splitlines():
|
||||
@@ -104,6 +252,7 @@ def list_fw_units() -> List[str]:
|
||||
logger.exception("systemctl list-units failed")
|
||||
return units
|
||||
|
||||
|
||||
def get_unit_fragment_path(service_name: str) -> Optional[str]:
|
||||
"""
|
||||
Use `systemctl show -p FragmentPath --value` to obtain the unit file path (if any).
|
||||
@@ -121,13 +270,28 @@ def get_unit_fragment_path(service_name: str) -> Optional[str]:
|
||||
logger.exception("systemctl show FragmentPath failed for %s", service_name)
|
||||
return None
|
||||
|
||||
def parse_unit_execstart(service_name: str) -> Dict:
|
||||
|
||||
class UnitParsed(BaseModel):
|
||||
service: str
|
||||
exec_start: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
script_path: Optional[str] = None
|
||||
qnum: Optional[int] = None
|
||||
extra: Optional[str] = None
|
||||
|
||||
|
||||
class UnitStatus(BaseModel):
|
||||
parsed: UnitParsed
|
||||
active: bool = False
|
||||
|
||||
|
||||
def parse_unit_execstart(service_name: str) -> UnitParsed:
|
||||
"""
|
||||
Parser for ExecStart using systemctl --no-pager show.
|
||||
- reconstructs argv[] entries if systemd returns a structured blob
|
||||
- finds /srv/fw-scripts/<name>.py anywhere in the commandline
|
||||
- extracts the first integer after the script as qnum
|
||||
Returns a dict: {service, exec_start, name, script_path, qnum, extra}
|
||||
Returns a UnitParsed instance.
|
||||
"""
|
||||
unit = service_name if service_name.endswith(".service") else service_name + ".service"
|
||||
|
||||
@@ -142,31 +306,17 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
||||
raw = (p.stdout or "").strip()
|
||||
except Exception:
|
||||
logger.exception("systemctl show ExecStart failed for %s", service_name)
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": None,
|
||||
"name": None,
|
||||
"script_path": None,
|
||||
"qnum": None,
|
||||
"extra": None,
|
||||
}
|
||||
return UnitParsed(service=service_name)
|
||||
|
||||
if not raw:
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": None,
|
||||
"name": None,
|
||||
"script_path": None,
|
||||
"qnum": None,
|
||||
"extra": None,
|
||||
}
|
||||
return UnitParsed(service=service_name)
|
||||
|
||||
exec_start = raw
|
||||
exec_start: str = raw
|
||||
|
||||
# If structured with argv[] entries, extract them (allow newlines).
|
||||
if "argv[]=" in raw:
|
||||
argv_entries = re.findall(r'argv\[\]=([^;]+)', raw, flags=re.DOTALL)
|
||||
argv_parts = []
|
||||
argv_parts: List[str] = []
|
||||
for a in argv_entries:
|
||||
v = a.strip()
|
||||
# strip surrounding quotes if present
|
||||
@@ -181,22 +331,15 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
||||
m = re.search(r'(/srv/fw-scripts/(?P<name>[A-Za-z0-9_.-]+)\.py)\b', exec_start)
|
||||
if not m:
|
||||
# return raw exec_start but no parsed metadata
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": exec_start,
|
||||
"name": None,
|
||||
"script_path": None,
|
||||
"qnum": None,
|
||||
"extra": None,
|
||||
}
|
||||
return UnitParsed(service=service_name, exec_start=exec_start)
|
||||
|
||||
script_path = m.group(1)
|
||||
name = m.group("name")
|
||||
|
||||
# Find first integer token after the script path (queue number)
|
||||
after = exec_start[m.end():].strip()
|
||||
qnum = None
|
||||
extra = None
|
||||
qnum: Optional[int] = None
|
||||
extra: Optional[str] = None
|
||||
if after:
|
||||
tokens = after.split()
|
||||
for i, t in enumerate(tokens):
|
||||
@@ -209,14 +352,7 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": exec_start,
|
||||
"name": name,
|
||||
"script_path": script_path,
|
||||
"qnum": qnum,
|
||||
"extra": extra,
|
||||
}
|
||||
return UnitParsed(service=service_name, exec_start=exec_start, name=name, script_path=script_path, qnum=qnum, extra=extra)
|
||||
|
||||
|
||||
def is_unit_active(service_name: str) -> bool:
|
||||
@@ -224,9 +360,11 @@ def is_unit_active(service_name: str) -> bool:
|
||||
p = subprocess.run(["systemctl", "is-active", "--quiet", unit])
|
||||
return p.returncode == 0
|
||||
|
||||
|
||||
def write_unit(service_name: str, exec_start: str, description: str = "", enable_at_boot: bool = False) -> str:
|
||||
"""
|
||||
Write unit file to default UNIT_DIR and daemon-reload. This writes to disk as systemd expects.
|
||||
Returns the path to the unit file written.
|
||||
"""
|
||||
unit_path = unit_path_for_name(service_name)
|
||||
unit_text = f"""[Unit]
|
||||
@@ -238,6 +376,7 @@ Type=simple
|
||||
ExecStart={exec_start}
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
TimeoutStopSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
|
||||
@@ -256,18 +395,22 @@ WantedBy=multi-user.target
|
||||
logger.warning("Failed to enable %s at boot", service_name)
|
||||
return unit_path
|
||||
|
||||
|
||||
def start_unit(service_name: str) -> None:
|
||||
subprocess.run(["systemctl", "start", _systemctl_unit_name(service_name)], check=True)
|
||||
logger.info("Started service %s", service_name)
|
||||
|
||||
|
||||
def stop_unit(service_name: str) -> None:
|
||||
subprocess.run(["systemctl", "stop", _systemctl_unit_name(service_name)], check=True)
|
||||
logger.info("Stopped service %s", service_name)
|
||||
|
||||
|
||||
def disable_unit(service_name: str) -> None:
|
||||
subprocess.run(["systemctl", "disable", _systemctl_unit_name(service_name)], check=False)
|
||||
logger.info("Disabled service %s", service_name)
|
||||
|
||||
|
||||
def remove_unit(service_name: str) -> None:
|
||||
"""
|
||||
Stop + disable the unit, remove the unit file using FragmentPath (if present),
|
||||
@@ -300,6 +443,7 @@ def remove_unit(service_name: str) -> None:
|
||||
except Exception:
|
||||
logger.exception("daemon-reload failed after removing unit %s", service_name)
|
||||
|
||||
|
||||
# ---------- venv + pip helpers ----------
|
||||
def create_venv(name: str, timeout: int = 60) -> str:
|
||||
venv_dir = venv_path_for(name)
|
||||
@@ -318,6 +462,16 @@ def create_venv(name: str, timeout: int = 60) -> str:
|
||||
raise RuntimeError("venv creation timed out")
|
||||
return venv_dir
|
||||
|
||||
|
||||
def jsonify_cmd_output(out: Dict[str, str]) -> str:
|
||||
s = ""
|
||||
if out.get("stdout"):
|
||||
s += "STDOUT:\n" + out["stdout"] + "\n"
|
||||
if out.get("stderr"):
|
||||
s += "STDERR:\n" + out["stderr"] + "\n"
|
||||
return s.strip()
|
||||
|
||||
|
||||
def pip_install_requirements(name: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]:
|
||||
venv_dir = create_venv(name)
|
||||
pip_path = os.path.join(venv_dir, "bin", "pip")
|
||||
@@ -338,74 +492,148 @@ def pip_install_requirements(name: str, requirements_path: str, timeout: int = 6
|
||||
logger.error("pip install timed out for %s", name)
|
||||
raise RuntimeError("pip install timed out")
|
||||
|
||||
def jsonify_cmd_output(out: Dict[str, str]) -> str:
|
||||
s = ""
|
||||
if out.get("stdout"):
|
||||
s += "STDOUT:\n" + out["stdout"] + "\n"
|
||||
if out.get("stderr"):
|
||||
s += "STDERR:\n" + out["stderr"] + "\n"
|
||||
return s.strip()
|
||||
|
||||
# ---------- Models ----------
|
||||
class ScriptInfo(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
|
||||
|
||||
class UnitMapping(BaseModel):
|
||||
service: str
|
||||
parsed: UnitParsed
|
||||
active: bool = False
|
||||
|
||||
|
||||
class ScriptWithStatus(ScriptInfo):
|
||||
"""
|
||||
Represents a script plus discovered unit mappings (if any).
|
||||
- mappings: list of UnitMapping for that script
|
||||
- requirements_exists: whether a requirements file exists on disk
|
||||
- is_protected_example: script originates from repository example folder and is immutable via API
|
||||
- requirements_is_protected_example: requirements file originates from repository example folder and is immutable via API
|
||||
"""
|
||||
mappings: List[UnitMapping] = Field(default_factory=list)
|
||||
requirements_exists: bool = False
|
||||
is_protected_example: bool = False
|
||||
requirements_is_protected_example: bool = False
|
||||
|
||||
|
||||
class ScriptUploadResponse(ScriptInfo):
|
||||
pip: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class EnableRequest(BaseModel):
|
||||
qnum: int
|
||||
qnum: int = Field(..., description="Queue number (integer)")
|
||||
service_name: Optional[str] = None
|
||||
extra_args: Optional[str] = None
|
||||
enable_at_boot: Optional[bool] = False
|
||||
|
||||
|
||||
class StatusForNameResponse(BaseModel):
|
||||
name: str
|
||||
mappings: List[UnitMapping]
|
||||
|
||||
|
||||
class OperationResult(BaseModel):
|
||||
status: str
|
||||
name: str
|
||||
qnum: Optional[int] = None
|
||||
service: Optional[str] = None
|
||||
python: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- Helper: group parsed units by script name ----------
|
||||
def _group_units_by_script(units: List[str]) -> Dict[str, List[Tuple[str, UnitParsed, bool]]]:
|
||||
"""
|
||||
Parse each unit and group by parsed.name (script name). Returns a dict:
|
||||
{ script_name: [ (svc, parsed, active), ... ], ... }
|
||||
If parsed.name is None, attempt to extract script name from service string using pattern.
|
||||
"""
|
||||
groups: DefaultDict[str, List[Tuple[str, UnitParsed, bool]]] = defaultdict(list)
|
||||
for svc in units:
|
||||
parsed = parse_unit_execstart(svc)
|
||||
try:
|
||||
active = is_unit_active(svc)
|
||||
except Exception:
|
||||
active = False
|
||||
|
||||
script_name = parsed.name
|
||||
if not script_name:
|
||||
# attempt to extract from service name like 'fw-script-<name>-q<N>'
|
||||
m = re.match(rf'^{re.escape(UNIT_PREFIX)}-(?P<name>[A-Za-z0-9_.-]+)-q\d+$', svc)
|
||||
if m:
|
||||
script_name = m.group("name")
|
||||
if not script_name:
|
||||
# fallback: group under special key so they won't be lost; use svc as key
|
||||
script_name = svc
|
||||
|
||||
groups[script_name].append((svc, parsed, active))
|
||||
return groups
|
||||
|
||||
|
||||
# ---------- Endpoints ----------
|
||||
# Note: Place status endpoints before the dynamic GET /{name} route to avoid routing conflicts.
|
||||
# NOTE: combined status info into GET /scripts below (replaces separate /status & /{name}/status endpoints)
|
||||
|
||||
@router.get("/status")
|
||||
def status_all():
|
||||
@router.get("", response_model=List[ScriptWithStatus])
|
||||
def list_scripts_with_status() -> List[ScriptWithStatus]:
|
||||
"""
|
||||
Discover all fw-script units via systemctl and report parsed ExecStart + active state.
|
||||
Return list of scripts plus per-script unit mappings/status.
|
||||
This combines the former /scripts and /scripts/status endpoints so clients get everything in one call.
|
||||
Each entry contains:
|
||||
- name, path
|
||||
- mappings: list of UnitMapping (service, parsed ExecStart, active flag)
|
||||
- requirements_exists: boolean
|
||||
- is_protected_example: boolean
|
||||
- requirements_is_protected_example: boolean
|
||||
"""
|
||||
units = list_fw_units()
|
||||
results: Dict[str, Dict] = {}
|
||||
for svc in units:
|
||||
parsed = parse_unit_execstart(svc)
|
||||
try:
|
||||
active = is_unit_active(svc)
|
||||
except Exception:
|
||||
active = False
|
||||
results[svc] = {"parsed": parsed, "active": active}
|
||||
logger.debug("Status queried: found %d units", len(results))
|
||||
return results
|
||||
out: List[ScriptWithStatus] = []
|
||||
|
||||
@router.get("/{name}/status")
|
||||
def status_for_name(name: str):
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
units = list_fw_units()
|
||||
matches = []
|
||||
prefix = f"{UNIT_PREFIX}-{name}-q"
|
||||
for svc in units:
|
||||
if svc.startswith(prefix):
|
||||
parsed = parse_unit_execstart(svc)
|
||||
try:
|
||||
active = is_unit_active(svc)
|
||||
except Exception:
|
||||
active = False
|
||||
matches.append({"service": svc, "parsed": parsed, "active": active})
|
||||
logger.debug("Status for %s -> %d matches", name, len(matches))
|
||||
return {"name": name, "mappings": matches}
|
||||
# build a list of units once for efficiency and group them by parsed script name
|
||||
all_units = list_fw_units()
|
||||
logger.debug("list_scripts_with_status: discovered %d fw units", len(all_units))
|
||||
grouped = _group_units_by_script(all_units)
|
||||
|
||||
@router.post("", response_model=ScriptInfo)
|
||||
# iterate script files on disk
|
||||
for fn in os.listdir(SCRIPT_DIR):
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
name = fn.rsplit(".", 1)[0]
|
||||
spath = os.path.join(SCRIPT_DIR, fn)
|
||||
|
||||
mappings: List[UnitMapping] = []
|
||||
# take groups[name] if present
|
||||
entries = grouped.get(name, [])
|
||||
for svc, parsed, active in entries:
|
||||
mappings.append(UnitMapping(service=svc, parsed=parsed, active=active))
|
||||
|
||||
req_exists = os.path.exists(requirements_path_for(name))
|
||||
out.append(
|
||||
ScriptWithStatus(
|
||||
name=name,
|
||||
path=spath,
|
||||
mappings=mappings,
|
||||
requirements_exists=req_exists,
|
||||
is_protected_example=is_example_script(name),
|
||||
requirements_is_protected_example=os.path.exists(example_requirements_path_for(name)),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug("Listed %d scripts with status", len(out))
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", response_model=ScriptUploadResponse)
|
||||
async def upload_script(
|
||||
script: UploadFile = File(...),
|
||||
name: str = Form(...),
|
||||
requirements: Optional[UploadFile] = File(None),
|
||||
):
|
||||
) -> ScriptUploadResponse:
|
||||
"""
|
||||
Upload a script with supplied 'name' and optional requirements file.
|
||||
On pip/venv install failure, cleanup uploaded files and venv and return 500 with details.
|
||||
|
||||
NOTE: This endpoint is multipart/form-data (UploadFile), so Swagger UI will show file upload widgets.
|
||||
"""
|
||||
# validate name
|
||||
try:
|
||||
@@ -414,9 +642,7 @@ async def upload_script(
|
||||
logger.warning("Invalid name provided: %s", name)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not script.filename.endswith(".py"):
|
||||
logger.warning("Upload rejected: script not .py (name=%s original=%s)", name, script.filename)
|
||||
raise HTTPException(status_code=400, detail="only .py scripts allowed")
|
||||
assert_not_example_script(name, "overwritten")
|
||||
|
||||
spath = script_path_for(name)
|
||||
if os.path.exists(spath):
|
||||
@@ -433,8 +659,8 @@ async def upload_script(
|
||||
logger.exception("Failed to write script file for %s: %s", name, e)
|
||||
raise HTTPException(status_code=500, detail="failed to save script")
|
||||
|
||||
req_path = None
|
||||
pip_output = None
|
||||
req_path: Optional[str] = None
|
||||
pip_output: Optional[Dict[str, str]] = None
|
||||
venv_created = False
|
||||
|
||||
try:
|
||||
@@ -445,6 +671,7 @@ async def upload_script(
|
||||
fh.write(req_data)
|
||||
logger.info("Saved requirements for %s at %s", name, req_path)
|
||||
try:
|
||||
# create venv (if needed) and run pip install; pip output is returned in response
|
||||
create_venv(name)
|
||||
venv_created = True
|
||||
res = pip_install_requirements(name, req_path)
|
||||
@@ -463,7 +690,11 @@ async def upload_script(
|
||||
shutil.rmtree(venv_path_for(name), ignore_errors=True)
|
||||
except Exception:
|
||||
logger.exception("Cleanup after pip failure partially failed for %s", name)
|
||||
# Return pip failure as 500 with message
|
||||
raise HTTPException(status_code=500, detail=f"pip install failed: {err_msg}")
|
||||
else:
|
||||
# If no requirements provided on upload, we simply keep uploaded script. (Per earlier conversation you can require requirements always if you want.)
|
||||
logger.debug("No requirements uploaded with script %s", name)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -485,25 +716,33 @@ async def upload_script(
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail="internal error during upload")
|
||||
|
||||
resp = {"name": name, "path": spath}
|
||||
resp: Dict[str, Any] = {"name": name, "path": spath}
|
||||
if pip_output is not None:
|
||||
resp["pip"] = pip_output
|
||||
return resp
|
||||
return ScriptUploadResponse(**resp)
|
||||
|
||||
@router.get("", response_model=List[ScriptInfo])
|
||||
def list_scripts():
|
||||
out = []
|
||||
for fn in os.listdir(SCRIPT_DIR):
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
name = fn.rsplit(".", 1)[0]
|
||||
out.append({"name": name, "path": os.path.join(SCRIPT_DIR, fn)})
|
||||
logger.debug("Listed %d scripts", len(out))
|
||||
return out
|
||||
|
||||
@router.get("/{name}")
|
||||
def download_script(name: str):
|
||||
# dynamic route - placed after /status and /{name}/status
|
||||
@router.get(
|
||||
"/{name}",
|
||||
response_class=FileResponse,
|
||||
responses={
|
||||
200: {
|
||||
"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}},
|
||||
"description": "Python script file (binary).",
|
||||
"headers": {
|
||||
"X-Script-Is-Protected-Example": {"schema": {"type": "string"}, "description": "'true' when script is a protected example"},
|
||||
"X-Requirements-Is-Protected-Example": {"schema": {"type": "string"}, "description": "'true' when requirements file is a protected example"},
|
||||
},
|
||||
},
|
||||
404: {"description": "Not found"},
|
||||
400: {"description": "Invalid name"},
|
||||
},
|
||||
)
|
||||
def download_script(name: str) -> FileResponse:
|
||||
"""
|
||||
Download a script as a binary file. Clients should request raw bytes (blob).
|
||||
Swagger/OpenAPI will document the response as binary.
|
||||
"""
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
@@ -513,10 +752,176 @@ def download_script(name: str):
|
||||
logger.warning("Download requested for missing script %s", name)
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
logger.info("Download script %s", name)
|
||||
return FileResponse(path, media_type="text/x-python", filename=f"{name}.py")
|
||||
|
||||
@router.post("/{name}/enable")
|
||||
def enable_script(name: str, req: EnableRequest):
|
||||
is_protected = is_example_script(name)
|
||||
req_is_protected = os.path.exists(example_requirements_path_for(name))
|
||||
|
||||
# Return generic octet-stream so clients treat as binary blob.
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/octet-stream",
|
||||
filename=f"{name}.py",
|
||||
headers={
|
||||
"X-Script-Is-Protected-Example": "true" if is_protected else "false",
|
||||
"X-Requirements-Is-Protected-Example": "true" if req_is_protected else "false",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{name}/requirements",
|
||||
response_class=FileResponse,
|
||||
responses={
|
||||
200: {
|
||||
"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}},
|
||||
"description": "requirements.txt file (binary).",
|
||||
"headers": {
|
||||
"X-Script-Is-Protected-Example": {"schema": {"type": "string"}, "description": "'true' when script is a protected example"},
|
||||
"X-Requirements-Is-Protected-Example": {"schema": {"type": "string"}, "description": "'true' when requirements file is a protected example"},
|
||||
},
|
||||
},
|
||||
404: {"description": "Not found"},
|
||||
400: {"description": "Invalid name"},
|
||||
},
|
||||
)
|
||||
def download_requirements(name: str) -> FileResponse:
|
||||
"""
|
||||
Download the stored requirements file for a script as binary blob.
|
||||
"""
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
path = requirements_path_for(name)
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail="requirements not found")
|
||||
logger.info("Download requirements for %s", name)
|
||||
is_protected = is_example_script(name)
|
||||
req_is_protected = os.path.exists(example_requirements_path_for(name))
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/octet-stream",
|
||||
filename=f"{name}-requirements.txt",
|
||||
headers={
|
||||
"X-Script-Is-Protected-Example": "true" if is_protected else "false",
|
||||
"X-Requirements-Is-Protected-Example": "true" if req_is_protected else "false",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{name}/requirements",
|
||||
responses={
|
||||
200: {"description": "Requirements replaced and pip output returned"},
|
||||
400: {"description": "Invalid name or bad request"},
|
||||
500: {"description": "pip install failed or storage error"},
|
||||
},
|
||||
)
|
||||
async def upload_requirements_install(name: str, requirements: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
"""
|
||||
Replace / upload the requirements file for a given script.
|
||||
- requirements: multipart file upload (UploadFile) — keeps Swagger UI file input.
|
||||
- This endpoint ALWAYS runs pip install -r <file> into the script's venv and returns pip stdout/stderr.
|
||||
If pip install fails, a 500 error is returned with the pip failure message.
|
||||
"""
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
assert_not_example_script(name, "modified")
|
||||
|
||||
# ensure script exists (we don't allow attaching requirements to non-existing script)
|
||||
spath = script_path_for(name)
|
||||
if not os.path.exists(spath):
|
||||
raise HTTPException(status_code=404, detail="script not found")
|
||||
|
||||
if not requirements:
|
||||
raise HTTPException(status_code=400, detail="requirements file required")
|
||||
|
||||
req_path = requirements_path_for(name)
|
||||
try:
|
||||
data = await requirements.read()
|
||||
with open(req_path, "wb") as fh:
|
||||
fh.write(data)
|
||||
logger.info("Saved requirements for %s at %s", name, req_path)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to write requirements for %s: %s", name, e)
|
||||
raise HTTPException(status_code=500, detail="failed to save requirements")
|
||||
|
||||
# Now ALWAYS install and return pip output (raise 500 on failure)
|
||||
try:
|
||||
create_venv(name)
|
||||
res = pip_install_requirements(name, req_path)
|
||||
logger.info("pip install completed for %s via requirements upload", name)
|
||||
return {"pip": {"stdout": res.get("stdout", ""), "stderr": res.get("stderr", "")}}
|
||||
except Exception as e:
|
||||
logger.exception("pip install failed for %s: %s", name, e)
|
||||
# keep the requirements file for inspection; return 500 with details
|
||||
raise HTTPException(status_code=500, detail=f"pip install failed: {str(e)}")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{name}/requirements",
|
||||
responses={
|
||||
200: {"description": "requirements removed and venv cleaned up"},
|
||||
404: {"description": "script or requirements not found"},
|
||||
500: {"description": "cleanup error"},
|
||||
},
|
||||
)
|
||||
def delete_requirements_and_cleanup(name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete only the requirements file for a script and attempt to remove the script's venv directory.
|
||||
Returns a summary of what was removed and any errors.
|
||||
"""
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
assert_not_example_script(name, "modified")
|
||||
|
||||
spath = script_path_for(name)
|
||||
if not os.path.exists(spath):
|
||||
raise HTTPException(status_code=404, detail="script not found")
|
||||
|
||||
rpath = requirements_path_for(name)
|
||||
vpath = venv_path_for(name)
|
||||
|
||||
removed = {"requirements_removed": False, "venv_removed": False}
|
||||
errors: List[str] = []
|
||||
|
||||
# remove requirements file
|
||||
try:
|
||||
if os.path.exists(rpath):
|
||||
os.remove(rpath)
|
||||
removed["requirements_removed"] = True
|
||||
logger.info("Removed requirements file %s", rpath)
|
||||
else:
|
||||
logger.debug("No requirements file to remove for %s", name)
|
||||
except Exception as e:
|
||||
logger.exception("Failed removing requirements file %s: %s", rpath, e)
|
||||
errors.append(f"remove_requirements {rpath}: {e}")
|
||||
|
||||
# remove venv directory
|
||||
try:
|
||||
if os.path.isdir(vpath):
|
||||
shutil.rmtree(vpath, ignore_errors=False)
|
||||
removed["venv_removed"] = True
|
||||
logger.info("Removed venv directory %s", vpath)
|
||||
else:
|
||||
logger.debug("No venv directory to remove for %s", name)
|
||||
except Exception as e:
|
||||
logger.exception("Failed removing venv %s: %s", vpath, e)
|
||||
errors.append(f"remove_venv {vpath}: {e}")
|
||||
|
||||
if errors:
|
||||
return JSONResponse(status_code=500, content={"removed": removed, "errors": errors})
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
@router.post("/{name}/enable", response_model=OperationResult)
|
||||
def enable_script(name: str, req: EnableRequest) -> OperationResult:
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
@@ -534,7 +939,7 @@ def enable_script(name: str, req: EnableRequest):
|
||||
exec_start += " " + req.extra_args
|
||||
|
||||
try:
|
||||
write_unit(service_name, exec_start, description=f"FW script {name} queue {qnum}", enable_at_boot=req.enable_at_boot)
|
||||
write_unit(service_name, exec_start, description=f"FW script {name} queue {qnum}", enable_at_boot=req.enable_at_boot or False)
|
||||
time.sleep(0.05)
|
||||
start_unit(service_name)
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -553,10 +958,11 @@ def enable_script(name: str, req: EnableRequest):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
logger.info("Enabled script %s on qnum=%d as service=%s (python=%s)", name, qnum, service_name, python_path)
|
||||
return {"status": "ok", "name": name, "qnum": qnum, "service": service_name, "python": python_path}
|
||||
return OperationResult(status="ok", name=name, qnum=qnum, service=service_name, python=python_path)
|
||||
|
||||
@router.post("/{name}/disable")
|
||||
def disable_script(name: str, qnum: int):
|
||||
|
||||
@router.post("/{name}/disable", response_model=OperationResult)
|
||||
def disable_script(name: str, qnum: int) -> OperationResult:
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
@@ -576,10 +982,11 @@ def disable_script(name: str, qnum: int):
|
||||
except Exception as e:
|
||||
logger.exception("Failed to disable unit %s: %s", service_name, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": "ok", "name": name, "qnum": qnum}
|
||||
return OperationResult(status="ok", name=name, qnum=qnum, service=service_name)
|
||||
|
||||
@router.delete("/{name}")
|
||||
def delete_script(name: str, qnum: Optional[int] = Query(None, description="If given, only remove the unit for this qnum; otherwise remove all units for the script")):
|
||||
|
||||
@router.delete("/{name}", response_model=Dict[str, Any])
|
||||
def delete_script(name: str, qnum: Optional[int] = Query(None, description="If given, only remove the unit for this qnum; otherwise remove all units for the script")) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a script and its associated resources.
|
||||
- If qnum is provided: stop/remove fw-script-<name>-q<qnum>.service (if present).
|
||||
@@ -592,6 +999,8 @@ def delete_script(name: str, qnum: Optional[int] = Query(None, description="If g
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
assert_not_example_script(name, "deleted")
|
||||
|
||||
removed_units: List[str] = []
|
||||
failed_units: List[str] = []
|
||||
errors: List[str] = []
|
||||
@@ -624,8 +1033,8 @@ def delete_script(name: str, qnum: Optional[int] = Query(None, description="If g
|
||||
spath = script_path_for(name)
|
||||
rpath = requirements_path_for(name)
|
||||
vpath = venv_path_for(name)
|
||||
file_removed = []
|
||||
file_failed = []
|
||||
file_removed: List[str] = []
|
||||
file_failed: List[str] = []
|
||||
|
||||
try:
|
||||
if os.path.exists(spath):
|
||||
@@ -657,7 +1066,7 @@ def delete_script(name: str, qnum: Optional[int] = Query(None, description="If g
|
||||
file_failed.append(vpath)
|
||||
errors.append(f"remove_venv {vpath}: {e}")
|
||||
|
||||
result = {
|
||||
result: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"units_removed": removed_units,
|
||||
"units_failed": failed_units,
|
||||
@@ -668,10 +1077,16 @@ def delete_script(name: str, qnum: Optional[int] = Query(None, description="If g
|
||||
logger.info("Delete script %s completed: removed_units=%d files_removed=%d errors=%d", name, len(removed_units), len(file_removed), len(errors))
|
||||
return result
|
||||
|
||||
|
||||
# ---------- Lifecycle helper ----------
|
||||
def register_lifecycle(app):
|
||||
def register_lifecycle(app) -> None:
|
||||
@app.on_event("startup")
|
||||
def _startup_event() -> None:
|
||||
logger.info("Startup: syncing and deploying protected example scripts from %s", EXAMPLE_SCRIPT_DIR)
|
||||
deploy_example_scripts_on_startup()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def _shutdown_event():
|
||||
def _shutdown_event() -> None:
|
||||
logger.info("Shutdown: stopping/removing manager-created units with prefix %s", UNIT_PREFIX)
|
||||
units = list_fw_units()
|
||||
for svc in units:
|
||||
|
||||
@@ -1,99 +1,194 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
"""HTTP API for starting, stopping, and inspecting packet capture sessions."""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from src.network_sniffer import (
|
||||
get_sniffer_status,
|
||||
start_afpacket_sniffer,
|
||||
stop_afpacket_sniffer,
|
||||
BRIDGE_CAPTURE_MODE_AF_PACKET,
|
||||
BRIDGE_CAPTURE_MODE_TC_EBPF,
|
||||
get_internal_debug_state,
|
||||
get_capture_session_status,
|
||||
start_capture_session,
|
||||
stop_capture_session,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ------------------------------
|
||||
# Pydantic Models
|
||||
# ------------------------------
|
||||
|
||||
class SnifferStartRequest(BaseModel):
|
||||
"""
|
||||
Request model for starting the sniffer on a specific bridge.
|
||||
"""
|
||||
bridge: str = Field(..., example="br0", description="Name of the Linux bridge to sniff on")
|
||||
"""Request payload for starting a packet capture session."""
|
||||
|
||||
|
||||
class SnifferStartResponse(BaseModel):
|
||||
"""
|
||||
Response model returned when sniffer starts successfully.
|
||||
"""
|
||||
started: bool = Field(..., description="Whether the sniffer was started successfully")
|
||||
bridge: str = Field(..., description="Bridge where the sniffer was started")
|
||||
|
||||
|
||||
class SnifferStopResponse(BaseModel):
|
||||
"""
|
||||
Response model returned when the sniffer stops successfully.
|
||||
"""
|
||||
stopped: bool = Field(..., description="Whether the sniffer was stopped successfully")
|
||||
|
||||
|
||||
class InterfaceSnifferStatus(BaseModel):
|
||||
"""
|
||||
Status of an individual interface monitored by the AF_PACKET sniffer.
|
||||
"""
|
||||
running: bool = Field(..., description="Whether the sniffer thread is active")
|
||||
exists: bool = Field(..., description="Whether the interface exists in /sys/class/net")
|
||||
up: bool = Field(..., description="Whether the interface is operationally UP")
|
||||
|
||||
|
||||
class SnifferStatusResponse(BaseModel):
|
||||
"""
|
||||
Response model for the sniffer status endpoint.
|
||||
"""
|
||||
interfaces: Dict[str, InterfaceSnifferStatus] = Field(
|
||||
..., description="Map of interface names to their sniffer status"
|
||||
bridge: Optional[str] = Field(
|
||||
None,
|
||||
example="br0",
|
||||
description="Bridge name to sniff.",
|
||||
)
|
||||
interface: Optional[str] = Field(
|
||||
None,
|
||||
example="eth0",
|
||||
description="Interface name to sniff.",
|
||||
)
|
||||
bridge_capture_mode: Optional[str] = Field(
|
||||
None,
|
||||
example="tc_ebpf",
|
||||
description="Bridge capture mode: 'tc_ebpf' or 'af_packet'. Ignored for interface capture.",
|
||||
)
|
||||
benchmark_mode: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Run capture hooks for measurement while skipping packet parsing, DB persistence, "
|
||||
"DPI enrichment, and live packet publication."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Endpoints
|
||||
# ------------------------------
|
||||
class SnifferStartResponse(BaseModel):
|
||||
"""Response payload for a successful sniffer start."""
|
||||
|
||||
started: bool = Field(..., description="True when a session was started.")
|
||||
session_id: str = Field(..., description="Unique session identifier.")
|
||||
target: str = Field(..., description="Started target name.")
|
||||
target_type: str = Field(..., description="Either 'bridge' or 'interface'.")
|
||||
capture_mode: str = Field(..., description="The effective capture mode used by the session.")
|
||||
benchmark_mode: bool = Field(False, description="Whether userspace packet processing is skipped.")
|
||||
|
||||
|
||||
class SnifferStopRequest(BaseModel):
|
||||
"""Optional stop payload for targeting a specific session."""
|
||||
|
||||
session_id: Optional[str] = Field(None, description="Session ID to stop.")
|
||||
|
||||
|
||||
class SnifferStopResponse(BaseModel):
|
||||
"""Response payload for stop operations."""
|
||||
|
||||
stopped: bool = Field(..., description="True when stop completed.")
|
||||
session_id: Optional[str] = Field(None, description="Stopped session ID if available.")
|
||||
target: Optional[str] = Field(None, description="Stopped target name.")
|
||||
target_type: Optional[str] = Field(None, description="'bridge', 'interface', or null.")
|
||||
|
||||
|
||||
class InterfaceSnifferStatus(BaseModel):
|
||||
"""Status details for a single network interface."""
|
||||
|
||||
running: bool = Field(..., description="Whether a sniffer is currently active.")
|
||||
exists: bool = Field(..., description="Whether the interface exists on the host.")
|
||||
up: bool = Field(..., description="Whether the interface is operationally up.")
|
||||
session_id: Optional[str] = Field(None, description="Owning capture session ID.")
|
||||
session_label: Optional[str] = Field(None, description="Human-readable session label.")
|
||||
capture_mode: Optional[str] = Field(None, description="Capture mode used by the owning session.")
|
||||
benchmark_mode: Optional[bool] = Field(None, description="Whether the owning session skips userspace processing.")
|
||||
|
||||
|
||||
class SnifferStatusResponse(BaseModel):
|
||||
"""Status response keyed by interface name."""
|
||||
|
||||
interfaces: Dict[str, InterfaceSnifferStatus] = Field(
|
||||
...,
|
||||
description="Map of interface names to status objects.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start", response_model=SnifferStartResponse)
|
||||
def sniffer_start(req: SnifferStartRequest):
|
||||
"""
|
||||
Start the AF_PACKET sniffer for the given bridge.
|
||||
"""
|
||||
def sniffer_start(req: SnifferStartRequest) -> SnifferStartResponse:
|
||||
"""Start one packet capture session for exactly one target."""
|
||||
if bool(req.bridge) == bool(req.interface):
|
||||
raise HTTPException(status_code=400, detail="Exactly one of 'bridge' or 'interface' must be provided")
|
||||
|
||||
try:
|
||||
start_afpacket_sniffer(req.bridge)
|
||||
return SnifferStartResponse(started=True, bridge=req.bridge)
|
||||
if req.interface:
|
||||
session_id = start_capture_session(
|
||||
req.interface,
|
||||
target_is_interface=True,
|
||||
benchmark_mode=req.benchmark_mode,
|
||||
)
|
||||
return SnifferStartResponse(
|
||||
started=True,
|
||||
session_id=session_id,
|
||||
target=req.interface,
|
||||
target_type="interface",
|
||||
capture_mode="af_packet",
|
||||
benchmark_mode=req.benchmark_mode,
|
||||
)
|
||||
|
||||
effective_capture_mode = req.bridge_capture_mode or BRIDGE_CAPTURE_MODE_TC_EBPF
|
||||
if effective_capture_mode not in {BRIDGE_CAPTURE_MODE_TC_EBPF, BRIDGE_CAPTURE_MODE_AF_PACKET}:
|
||||
raise HTTPException(status_code=400, detail="bridge_capture_mode must be 'tc_ebpf' or 'af_packet'")
|
||||
session_id = start_capture_session(
|
||||
req.bridge,
|
||||
target_is_interface=False,
|
||||
bridge_capture_mode=effective_capture_mode,
|
||||
benchmark_mode=req.benchmark_mode,
|
||||
)
|
||||
return SnifferStartResponse(
|
||||
started=True,
|
||||
session_id=session_id,
|
||||
target=req.bridge,
|
||||
target_type="bridge",
|
||||
capture_mode=effective_capture_mode,
|
||||
benchmark_mode=req.benchmark_mode,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}") from exc
|
||||
|
||||
|
||||
@router.post("/stop", response_model=SnifferStopResponse)
|
||||
def sniffer_stop():
|
||||
"""
|
||||
Stop the AF_PACKET sniffer (if running).
|
||||
"""
|
||||
def sniffer_stop(
|
||||
q_bridge: Optional[str] = Query(
|
||||
None,
|
||||
alias="bridge",
|
||||
description="Stop sockets for this bridge.",
|
||||
),
|
||||
q_interface: Optional[str] = Query(
|
||||
None,
|
||||
alias="interface",
|
||||
description="Stop sockets for this interface.",
|
||||
),
|
||||
body: SnifferStopRequest = Body(...),
|
||||
) -> SnifferStopResponse:
|
||||
"""Stop by session ID, target query, or globally when no selector is given."""
|
||||
if body and body.session_id:
|
||||
try:
|
||||
stop_afpacket_sniffer()
|
||||
return SnifferStopResponse(stopped=True)
|
||||
stop_capture_session(session_id=body.session_id)
|
||||
return SnifferStopResponse(stopped=True, session_id=body.session_id, target=None, target_type=None)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop sniffer: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop session {body.session_id}: {exc}") from exc
|
||||
|
||||
if q_bridge and q_interface:
|
||||
raise HTTPException(status_code=400, detail="Only one of 'bridge' or 'interface' may be provided")
|
||||
|
||||
try:
|
||||
if q_interface:
|
||||
stop_capture_session(target=q_interface, target_is_interface=True)
|
||||
return SnifferStopResponse(stopped=True, session_id=None, target=q_interface, target_type="interface")
|
||||
|
||||
if q_bridge:
|
||||
stop_capture_session(target=q_bridge, target_is_interface=False)
|
||||
return SnifferStopResponse(stopped=True, session_id=None, target=q_bridge, target_type="bridge")
|
||||
|
||||
stop_capture_session()
|
||||
return SnifferStopResponse(stopped=True, session_id=None, target=None, target_type=None)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop sniffer: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/status", response_model=SnifferStatusResponse)
|
||||
def sniffer_status():
|
||||
"""
|
||||
Return the sniffer status information.
|
||||
"""
|
||||
def sniffer_status() -> SnifferStatusResponse:
|
||||
"""Return current capture-session status per interface."""
|
||||
try:
|
||||
raw = get_sniffer_status()
|
||||
# Convert raw dict → typed model
|
||||
typed = {
|
||||
k: InterfaceSnifferStatus(**v)
|
||||
for k, v in raw.items()
|
||||
}
|
||||
raw: Dict[str, Dict[str, Any]] = get_capture_session_status()
|
||||
typed = {key: InterfaceSnifferStatus(**value) for key, value in raw.items()}
|
||||
return SnifferStatusResponse(interfaces=typed)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to query sniffer status: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to query sniffer status: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/debug")
|
||||
def sniffer_debug() -> Dict[str, Any]:
|
||||
"""Return internal capture-session and packet-tracker debug state."""
|
||||
try:
|
||||
return get_internal_debug_state()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to query sniffer debug state: {exc}") from exc
|
||||
|
||||
160
backend/src/config.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Runtime configuration for the backend service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _env_str(name: str, default: str) -> str:
|
||||
value = os.getenv(name)
|
||||
return value if value not in (None, "") else default
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
value = os.getenv(name)
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return int(value)
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
value = os.getenv(name)
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return float(value)
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendSettings:
|
||||
db_dsn: str
|
||||
log_level: str
|
||||
db_pool_min_size: int
|
||||
db_pool_max_size: int
|
||||
broadcaster_queue_maxsize: int
|
||||
packet_tracker_finalize_delay_seconds: float
|
||||
packet_tracker_retention_seconds: float
|
||||
packet_tracker_min_flush_interval_seconds: float
|
||||
packet_tracker_persist_timeout_seconds: float
|
||||
packet_tracker_batch_persist_timeout_seconds: float
|
||||
packet_tracker_persist_retry_backoff_seconds: float
|
||||
packet_tracker_persist_retry_backoff_max_seconds: float
|
||||
packet_tracker_error_log_interval_seconds: float
|
||||
packet_tracker_flush_batch_size: int
|
||||
packet_tracker_max_entries: int
|
||||
packet_tracker_max_persist_failures: int
|
||||
packet_tracker_max_dirty_age_seconds: float
|
||||
packet_tracker_stop_join_timeout_seconds: float
|
||||
packet_tracker_reject_correlation_window_seconds: float
|
||||
sniffer_buffer_capacity: int
|
||||
sniffer_socket_rcvbuf_bytes: int
|
||||
sniffer_selector_timeout_seconds: float
|
||||
sniffer_recv_bytes: int
|
||||
sniffer_buffer_drain_interval_seconds: float
|
||||
sniffer_thread_join_timeout_seconds: float
|
||||
bridge_bpf_build_dir: str
|
||||
bridge_telemetry_raw_sample_every: int
|
||||
bridge_telemetry_meta_sample_every: int
|
||||
bridge_telemetry_ingress_perf_pages: int
|
||||
bridge_telemetry_meta_perf_pages: int
|
||||
bridge_telemetry_event_queue_maxsize: int
|
||||
bridge_telemetry_queue_recovery_size: int
|
||||
bridge_telemetry_drop_log_interval_seconds: float
|
||||
bridge_link_state_thread_join_timeout_seconds: float
|
||||
bridge_link_state_failure_holdoff_seconds: float
|
||||
bridge_link_state_recovery_holdoff_seconds: float
|
||||
bridge_link_state_degraded_recheck_seconds: float
|
||||
telemetry_process_stop_timeout_seconds: float
|
||||
telemetry_reader_join_timeout_seconds: float
|
||||
tshark_enabled: bool
|
||||
tshark_display_filter: str
|
||||
tshark_try_heuristic_first: bool
|
||||
tshark_cache_ttl_seconds: float
|
||||
tshark_match_window_ms: int
|
||||
tshark_reader_join_timeout_seconds: float
|
||||
tshark_process_stop_timeout_seconds: float
|
||||
|
||||
|
||||
def load_settings() -> BackendSettings:
|
||||
return BackendSettings(
|
||||
db_dsn=_env_str("BACKEND_DB_DSN", "postgresql://mitm_user:mitm_password@localhost:5432/mitm_db"),
|
||||
log_level=_env_str("BACKEND_LOG_LEVEL", "DEBUG"),
|
||||
db_pool_min_size=_env_int("BACKEND_DB_POOL_MIN_SIZE", 1),
|
||||
db_pool_max_size=_env_int("BACKEND_DB_POOL_MAX_SIZE", 5),
|
||||
broadcaster_queue_maxsize=_env_int("BACKEND_BROADCAST_QUEUE_MAXSIZE", 1024),
|
||||
packet_tracker_finalize_delay_seconds=_env_float("BACKEND_PACKET_TRACKER_FINALIZE_DELAY_SECONDS", 0.25),
|
||||
packet_tracker_retention_seconds=_env_float("BACKEND_PACKET_TRACKER_RETENTION_SECONDS", 10.0),
|
||||
packet_tracker_min_flush_interval_seconds=_env_float("BACKEND_PACKET_TRACKER_MIN_FLUSH_INTERVAL_SECONDS", 0.05),
|
||||
packet_tracker_persist_timeout_seconds=_env_float("BACKEND_PACKET_TRACKER_PERSIST_TIMEOUT_SECONDS", 2.0),
|
||||
packet_tracker_batch_persist_timeout_seconds=_env_float(
|
||||
"BACKEND_PACKET_TRACKER_BATCH_PERSIST_TIMEOUT_SECONDS",
|
||||
10.0,
|
||||
),
|
||||
packet_tracker_persist_retry_backoff_seconds=_env_float(
|
||||
"BACKEND_PACKET_TRACKER_PERSIST_RETRY_BACKOFF_SECONDS",
|
||||
0.25,
|
||||
),
|
||||
packet_tracker_persist_retry_backoff_max_seconds=_env_float(
|
||||
"BACKEND_PACKET_TRACKER_PERSIST_RETRY_BACKOFF_MAX_SECONDS",
|
||||
5.0,
|
||||
),
|
||||
packet_tracker_error_log_interval_seconds=_env_float("BACKEND_PACKET_TRACKER_ERROR_LOG_INTERVAL_SECONDS", 5.0),
|
||||
packet_tracker_flush_batch_size=max(1, _env_int("BACKEND_PACKET_TRACKER_FLUSH_BATCH_SIZE", 500)),
|
||||
packet_tracker_max_entries=max(1, _env_int("BACKEND_PACKET_TRACKER_MAX_ENTRIES", 50_000)),
|
||||
packet_tracker_max_persist_failures=max(1, _env_int("BACKEND_PACKET_TRACKER_MAX_PERSIST_FAILURES", 3)),
|
||||
packet_tracker_max_dirty_age_seconds=_env_float("BACKEND_PACKET_TRACKER_MAX_DIRTY_AGE_SECONDS", 60.0),
|
||||
packet_tracker_stop_join_timeout_seconds=_env_float("BACKEND_PACKET_TRACKER_STOP_JOIN_TIMEOUT_SECONDS", 2.0),
|
||||
packet_tracker_reject_correlation_window_seconds=_env_float(
|
||||
"BACKEND_PACKET_TRACKER_REJECT_CORRELATION_WINDOW_SECONDS",
|
||||
1.0,
|
||||
),
|
||||
sniffer_buffer_capacity=_env_int("BACKEND_SNIFFER_BUFFER_CAPACITY", 20_000),
|
||||
sniffer_socket_rcvbuf_bytes=_env_int("BACKEND_SNIFFER_SOCKET_RCVBUF_BYTES", 4 * 1024 * 1024),
|
||||
sniffer_selector_timeout_seconds=_env_float("BACKEND_SNIFFER_SELECTOR_TIMEOUT_SECONDS", 1.0),
|
||||
sniffer_recv_bytes=_env_int("BACKEND_SNIFFER_RECV_BYTES", 65_536),
|
||||
sniffer_buffer_drain_interval_seconds=_env_float("BACKEND_SNIFFER_BUFFER_DRAIN_INTERVAL_SECONDS", 5.0),
|
||||
sniffer_thread_join_timeout_seconds=_env_float("BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS", 2.0),
|
||||
bridge_bpf_build_dir=_env_str("BACKEND_BRIDGE_BPF_BUILD_DIR", "/tmp/mitm-bpf"),
|
||||
bridge_telemetry_raw_sample_every=max(0, _env_int("BACKEND_BRIDGE_TELEMETRY_RAW_SAMPLE_EVERY", 1)),
|
||||
bridge_telemetry_meta_sample_every=max(0, _env_int("BACKEND_BRIDGE_TELEMETRY_META_SAMPLE_EVERY", 1)),
|
||||
bridge_telemetry_ingress_perf_pages=max(1, _env_int("BACKEND_BRIDGE_TELEMETRY_INGRESS_PERF_PAGES", 256)),
|
||||
bridge_telemetry_meta_perf_pages=max(1, _env_int("BACKEND_BRIDGE_TELEMETRY_META_PERF_PAGES", 128)),
|
||||
bridge_telemetry_event_queue_maxsize=max(1, _env_int("BACKEND_BRIDGE_TELEMETRY_EVENT_QUEUE_MAXSIZE", 20_000)),
|
||||
bridge_telemetry_queue_recovery_size=max(0, _env_int("BACKEND_BRIDGE_TELEMETRY_QUEUE_RECOVERY_SIZE", 1_000)),
|
||||
bridge_telemetry_drop_log_interval_seconds=_env_float("BACKEND_BRIDGE_TELEMETRY_DROP_LOG_INTERVAL_SECONDS", 5.0),
|
||||
bridge_link_state_thread_join_timeout_seconds=_env_float(
|
||||
"BACKEND_BRIDGE_LINK_STATE_THREAD_JOIN_TIMEOUT_SECONDS",
|
||||
2.0,
|
||||
),
|
||||
bridge_link_state_failure_holdoff_seconds=_env_float(
|
||||
"BACKEND_BRIDGE_LINK_STATE_FAILURE_HOLDOFF_SECONDS",
|
||||
0.75,
|
||||
),
|
||||
bridge_link_state_recovery_holdoff_seconds=_env_float(
|
||||
"BACKEND_BRIDGE_LINK_STATE_RECOVERY_HOLDOFF_SECONDS",
|
||||
1.0,
|
||||
),
|
||||
bridge_link_state_degraded_recheck_seconds=_env_float(
|
||||
"BACKEND_BRIDGE_LINK_STATE_DEGRADED_RECHECK_SECONDS",
|
||||
0.5,
|
||||
),
|
||||
telemetry_process_stop_timeout_seconds=_env_float("BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS", 3.0),
|
||||
telemetry_reader_join_timeout_seconds=_env_float("BACKEND_TELEMETRY_READER_JOIN_TIMEOUT_SECONDS", 2.0),
|
||||
tshark_enabled=_env_bool("BACKEND_TSHARK_ENABLED", True),
|
||||
tshark_display_filter=_env_str("BACKEND_TSHARK_DISPLAY_FILTER", ""),
|
||||
tshark_try_heuristic_first=_env_bool("BACKEND_TSHARK_TRY_HEURISTIC_FIRST", True),
|
||||
tshark_cache_ttl_seconds=_env_float("BACKEND_TSHARK_CACHE_TTL_SECONDS", 5.0),
|
||||
tshark_match_window_ms=_env_int("BACKEND_TSHARK_MATCH_WINDOW_MS", 5_000),
|
||||
tshark_reader_join_timeout_seconds=_env_float("BACKEND_TSHARK_READER_JOIN_TIMEOUT_SECONDS", 2.0),
|
||||
tshark_process_stop_timeout_seconds=_env_float("BACKEND_TSHARK_PROCESS_STOP_TIMEOUT_SECONDS", 3.0),
|
||||
)
|
||||
|
||||
|
||||
settings = load_settings()
|
||||
@@ -1,31 +1,29 @@
|
||||
# src/main.py
|
||||
"""FastAPI application entrypoint and runtime wiring."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
from src.api import packet_scripting_api
|
||||
from src.api import nft_manager
|
||||
from src.utilities.packet_broadcaster import PacketBroadcaster
|
||||
import src.shared_objects as shared_objects
|
||||
from src.utilities.database import DatabasePool
|
||||
import src.api.network_api as network_api
|
||||
import src.api.sniffer_api as sniffer_api
|
||||
from src.api import nft_api
|
||||
import src.shared_objects as shared_objects
|
||||
from src.api import nft_manager
|
||||
from src.api import analysis_api
|
||||
from src.api import packet_api
|
||||
import src.api.nftables_api as nftables_api
|
||||
from src.api import packet_scripting_api
|
||||
from src.config import settings
|
||||
from src.utilities.database import DatabasePool
|
||||
from src.utilities.packet_broadcaster import PacketBroadcaster
|
||||
|
||||
# ---- Config -----------------------------------------------------------
|
||||
DB_DSN = "postgresql://mitm_user:mitm_password@localhost:5432/mitm_db"
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# ---- Globals -----------------------------------------------
|
||||
# Create DatabasePool instance (pool created on startup)
|
||||
shared_objects.db = DatabasePool(DB_DSN)
|
||||
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.DEBUG))
|
||||
shared_objects.db = DatabasePool(
|
||||
settings.db_dsn,
|
||||
min_size=settings.db_pool_min_size,
|
||||
max_size=settings.db_pool_max_size,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
root_path="/api",
|
||||
@@ -45,62 +43,89 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------------
|
||||
# Startup / Shutdown
|
||||
# ---------------------
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
"""
|
||||
Initialize DB pool and broadcaster on the FastAPI event loop and
|
||||
publish them into shared_objects so other modules (sniffer, routers)
|
||||
can access them.
|
||||
"""
|
||||
async def on_startup() -> None:
|
||||
"""Initialize shared runtime objects on the FastAPI event loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
shared_objects.web_loop = loop
|
||||
|
||||
# Initialize DB pool bound to this loop
|
||||
try:
|
||||
await shared_objects.db.init_pool()
|
||||
except Exception:
|
||||
logging.exception("Failed to initialize DB pool")
|
||||
raise
|
||||
|
||||
# Create broadcaster and attach to DB so DB.insert_packet can publish updates
|
||||
try:
|
||||
shared_objects.broadcaster = PacketBroadcaster(loop)
|
||||
shared_objects.broadcaster = PacketBroadcaster(loop, queue_maxsize=settings.broadcaster_queue_maxsize)
|
||||
shared_objects.db.broadcaster = shared_objects.broadcaster
|
||||
except Exception:
|
||||
logging.exception("Failed to create/attach broadcaster")
|
||||
# continue — DB is primary; broadcaster optional
|
||||
|
||||
# Drain any buffered packets from the sniffer (if it started earlier)
|
||||
try:
|
||||
# import sniffer here to avoid circular imports at module import time
|
||||
shared_objects.network_broadcaster = PacketBroadcaster(loop, queue_maxsize=settings.broadcaster_queue_maxsize)
|
||||
except Exception:
|
||||
logging.exception("Failed to create network broadcaster")
|
||||
|
||||
try:
|
||||
from src import network_sniffer as sniffer
|
||||
|
||||
# sniffer provides drain_buffer_to_shared_db()
|
||||
try:
|
||||
sniffer.drain_buffer_to_shared_db()
|
||||
except Exception:
|
||||
logging.exception("Failed to drain sniffer buffer")
|
||||
except ImportError:
|
||||
# sniffer not present or not importable; skip
|
||||
logging.debug("sniffer module not importable at startup; skipping buffer drain")
|
||||
logging.debug("Sniffer module not importable at startup; skipping buffer drain")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""
|
||||
Shutdown actions: stop network API and close DB pool if present.
|
||||
"""
|
||||
# try to shut down network API components
|
||||
async def shutdown_event() -> None:
|
||||
"""Stop network resources and release shared runtime objects."""
|
||||
try:
|
||||
from src.network_sniffer import stop_capture_session
|
||||
|
||||
stop_capture_session()
|
||||
except Exception:
|
||||
logging.exception("Failed to stop capture sessions during shutdown")
|
||||
|
||||
try:
|
||||
network_api.shutdown_network_api()
|
||||
except Exception:
|
||||
logging.exception("Error shutting down network API")
|
||||
|
||||
# close DB pool if available in shared_objects
|
||||
try:
|
||||
from src.utilities.bridge_telemetry import bridge_telemetry_manager
|
||||
|
||||
bridge_telemetry_manager.stop()
|
||||
except Exception:
|
||||
logging.exception("Failed to stop bridge telemetry collector")
|
||||
|
||||
try:
|
||||
from src.utilities.tshark_manager import tshark_manager
|
||||
|
||||
tshark_manager.stop()
|
||||
except Exception:
|
||||
logging.exception("Failed to stop tshark workers")
|
||||
|
||||
try:
|
||||
from src.utilities.packet_tracker import packet_tracker
|
||||
|
||||
packet_tracker.stop()
|
||||
except Exception:
|
||||
logging.exception("Failed to stop packet tracker")
|
||||
|
||||
try:
|
||||
if shared_objects.broadcaster is not None:
|
||||
await shared_objects.broadcaster.close()
|
||||
except Exception:
|
||||
logging.exception("Failed to close packet broadcaster during shutdown")
|
||||
|
||||
try:
|
||||
if shared_objects.network_broadcaster is not None:
|
||||
await shared_objects.network_broadcaster.close()
|
||||
except Exception:
|
||||
logging.exception("Failed to close network broadcaster during shutdown")
|
||||
|
||||
try:
|
||||
web_db = getattr(shared_objects, "db", None)
|
||||
if web_db is not None:
|
||||
@@ -108,37 +133,29 @@ async def shutdown_event():
|
||||
except Exception:
|
||||
logging.exception("Failed to close DB pool during shutdown")
|
||||
|
||||
# clear shared runtime objects (optional cleanup)
|
||||
try:
|
||||
shared_objects.db = None
|
||||
shared_objects.broadcaster = None
|
||||
shared_objects.network_broadcaster = None
|
||||
shared_objects.web_loop = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Basic Endpoints
|
||||
# ---------------------
|
||||
|
||||
@app.get("/hello")
|
||||
def hello():
|
||||
def hello() -> dict[str, str]:
|
||||
"""Simple health-check endpoint."""
|
||||
return {"message": "Hello from FastAPI 🎉"}
|
||||
|
||||
|
||||
@app.get("/versions")
|
||||
def versions():
|
||||
def versions() -> dict[str, str]:
|
||||
"""Return runtime Python version."""
|
||||
message = os.popen("python --version").read().strip()
|
||||
return {"message": message}
|
||||
|
||||
# ---------------------
|
||||
# Routers
|
||||
# ---------------------
|
||||
|
||||
app.include_router(network_api.router, prefix="/network", tags=["network"])
|
||||
app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"])
|
||||
app.include_router(packet_api.router, prefix="/packets", tags=["packets"])
|
||||
#app.include_router(nftables_api.router, prefix="/nftables", tags=["nftables"])
|
||||
#app.include_router(nft_api.router, prefix="/nft", tags=["nft"])
|
||||
app.include_router(analysis_api.router, prefix="/analysis", tags=["analysis"])
|
||||
app.include_router(nft_manager.router, tags=["firewall"])
|
||||
app.include_router(packet_scripting_api.router, prefix="/scripts", tags=["scripts"])
|
||||
packet_scripting_api.register_lifecycle(app)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
"""Shared runtime objects initialized during FastAPI startup."""
|
||||
|
||||
# These are filled at FastAPI startup
|
||||
# DB instance
|
||||
db = None
|
||||
import asyncio
|
||||
from typing import Any, Optional
|
||||
|
||||
db: Any = None
|
||||
web_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
broadcaster = None
|
||||
broadcaster: Any = None
|
||||
network_broadcaster: Any = None
|
||||
|
||||
995
backend/src/utilities/bridge_link_state_manager.py
Normal file
@@ -0,0 +1,995 @@
|
||||
"""Event-driven watcher that propagates bridge member failures and selected link settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pyroute2 import IPRoute
|
||||
|
||||
from src.config import settings
|
||||
from src.utilities.interface_bridge_helpers import (
|
||||
check_interface_exists,
|
||||
get_bridge_ports_once,
|
||||
read_interface_admin_up,
|
||||
read_interface_carrier,
|
||||
read_interface_ethernet_profile,
|
||||
read_interface_mtu,
|
||||
read_interface_operstate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("bridge_link_state_manager")
|
||||
|
||||
_ETHTOOL_BIN = "/usr/sbin/ethtool" if os.path.exists("/usr/sbin/ethtool") else "ethtool"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EthernetProfile:
|
||||
"""Subset of ethtool settings that can be mirrored across peer ports."""
|
||||
|
||||
speed_mbps: Optional[int]
|
||||
duplex: Optional[str]
|
||||
autoneg: Optional[bool]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize the profile for API responses."""
|
||||
return {
|
||||
"speed_mbps": self.speed_mbps,
|
||||
"duplex": self.duplex,
|
||||
"autoneg": self.autoneg,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemberLinkState:
|
||||
"""Current state for one bridge member."""
|
||||
|
||||
ifname: str
|
||||
admin_up: Optional[bool]
|
||||
carrier_up: Optional[bool]
|
||||
operstate: Optional[str]
|
||||
mtu: Optional[int]
|
||||
ethernet_profile: Optional[EthernetProfile]
|
||||
|
||||
@property
|
||||
def link_ready(self) -> bool:
|
||||
"""Return whether the member currently looks healthy enough to forward."""
|
||||
if self.admin_up is not True:
|
||||
return False
|
||||
if self.carrier_up is False:
|
||||
return False
|
||||
if self.operstate in {"down", "lowerlayerdown", "notpresent"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
def to_dict(self, suppressed: bool = False) -> Dict[str, Any]:
|
||||
"""Serialize member state for API responses."""
|
||||
return {
|
||||
"ifname": self.ifname,
|
||||
"admin_up": self.admin_up,
|
||||
"carrier_up": self.carrier_up,
|
||||
"operstate": self.operstate,
|
||||
"mtu": self.mtu,
|
||||
"ethernet_profile": self.ethernet_profile.to_dict() if self.ethernet_profile is not None else None,
|
||||
"link_ready": self.link_ready,
|
||||
"suppressed": suppressed,
|
||||
}
|
||||
|
||||
|
||||
class BridgeLinkStateWatcher:
|
||||
"""Watch one bridge and mirror member failures to the other bridge members."""
|
||||
|
||||
def __init__(self, bridge_name: str, recovery_holdoff_seconds: float) -> None:
|
||||
self.bridge_name = bridge_name
|
||||
self.recovery_holdoff_seconds = recovery_holdoff_seconds
|
||||
self._stop_event = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._wake_r, self._wake_w = os.pipe()
|
||||
os.set_blocking(self._wake_r, False)
|
||||
os.set_blocking(self._wake_w, False)
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
daemon=True,
|
||||
name=f"bridge-link-state-{bridge_name}",
|
||||
)
|
||||
self._running = False
|
||||
self._suppressed_members: dict[str, bool] = {}
|
||||
self._failing_since: dict[str, float] = {}
|
||||
self._settle_deadlines: dict[str, float] = {}
|
||||
self._managed_event_deadlines: dict[str, float] = {}
|
||||
self._config_change_deadlines: dict[str, float] = {}
|
||||
self._sync_attempt_deadlines: dict[tuple[str, str], tuple[str, float]] = {}
|
||||
self._all_clear_since: Optional[float] = None
|
||||
self._degraded = False
|
||||
self._last_event_ts: Optional[float] = None
|
||||
self._last_error: Optional[str] = None
|
||||
self._last_action: Optional[str] = None
|
||||
self._member_states: dict[str, MemberLinkState] = {}
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the watcher thread."""
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the watcher and restore interfaces that were suppressed by it."""
|
||||
self._stop_event.set()
|
||||
self._wake_thread()
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=settings.bridge_link_state_thread_join_timeout_seconds)
|
||||
|
||||
try:
|
||||
self._restore_suppressed_members(reason="watcher_stopped")
|
||||
except Exception:
|
||||
logger.exception("Failed to restore suppressed members for bridge=%s", self.bridge_name)
|
||||
|
||||
with self._lock:
|
||||
self._running = False
|
||||
|
||||
for fd in (self._wake_r, self._wake_w):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable snapshot of the watcher state."""
|
||||
with self._lock:
|
||||
members = {
|
||||
ifname: state.to_dict(suppressed=ifname in self._suppressed_members)
|
||||
for ifname, state in sorted(self._member_states.items())
|
||||
}
|
||||
return {
|
||||
"bridge": self.bridge_name,
|
||||
"active": self._running and self._thread.is_alive() and not self._stop_event.is_set(),
|
||||
"event_driven": True,
|
||||
"last_event_ts": self._last_event_ts,
|
||||
"last_error": self._last_error,
|
||||
"last_action": self._last_action,
|
||||
"suppressed_members": sorted(self._suppressed_members),
|
||||
"failure_holdoff_seconds": settings.bridge_link_state_failure_holdoff_seconds,
|
||||
"recovery_holdoff_seconds": self.recovery_holdoff_seconds,
|
||||
"degraded_recheck_seconds": settings.bridge_link_state_degraded_recheck_seconds,
|
||||
"members": members,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
with IPRoute() as ipr:
|
||||
ipr.bind()
|
||||
self._evaluate_bridge_state(reason="watcher_started")
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
timeout = self._next_wait_timeout()
|
||||
ready, _, _ = select.select([ipr, self._wake_r], [], [], timeout)
|
||||
except Exception as exc:
|
||||
logger.exception("Bridge link-state select failed for bridge=%s", self.bridge_name)
|
||||
with self._lock:
|
||||
self._last_error = str(exc)
|
||||
continue
|
||||
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
if self._wake_r in ready:
|
||||
self._drain_wake_pipe()
|
||||
continue
|
||||
|
||||
if ipr in ready:
|
||||
try:
|
||||
messages = ipr.get()
|
||||
except Exception as exc:
|
||||
logger.exception("Bridge link-state netlink read failed for bridge=%s", self.bridge_name)
|
||||
with self._lock:
|
||||
self._last_error = str(exc)
|
||||
continue
|
||||
|
||||
if any(msg.get("event") in {"RTM_NEWLINK", "RTM_DELLINK"} for msg in messages):
|
||||
source_ifname = self._pick_source_interface(messages)
|
||||
self._evaluate_bridge_state(reason="netlink_event", source_ifname=source_ifname)
|
||||
continue
|
||||
|
||||
self._evaluate_bridge_state(reason="recovery_deadline")
|
||||
|
||||
def _evaluate_bridge_state(self, reason: str, source_ifname: Optional[str] = None) -> None:
|
||||
with self._lock:
|
||||
previous_states = dict(self._member_states)
|
||||
|
||||
members = [iface for iface in get_bridge_ports_once(self.bridge_name) if check_interface_exists(iface)]
|
||||
states = {
|
||||
iface: self._read_member_state(iface, previous_states.get(iface))
|
||||
for iface in members
|
||||
}
|
||||
now = time.time()
|
||||
|
||||
with self._lock:
|
||||
self._last_event_ts = now
|
||||
self._last_error = None
|
||||
self._member_states = states
|
||||
self._suppressed_members = {
|
||||
iface: restore_up
|
||||
for iface, restore_up in self._suppressed_members.items()
|
||||
if iface in states
|
||||
}
|
||||
self._failing_since = {
|
||||
iface: first_seen
|
||||
for iface, first_seen in self._failing_since.items()
|
||||
if iface in states
|
||||
}
|
||||
self._settle_deadlines = {
|
||||
iface: deadline
|
||||
for iface, deadline in self._settle_deadlines.items()
|
||||
if iface in states and deadline > now
|
||||
}
|
||||
self._managed_event_deadlines = {
|
||||
iface: deadline
|
||||
for iface, deadline in self._managed_event_deadlines.items()
|
||||
if iface in states and deadline > now
|
||||
}
|
||||
self._config_change_deadlines = {
|
||||
iface: deadline
|
||||
for iface, deadline in self._config_change_deadlines.items()
|
||||
if iface in states and deadline > now
|
||||
}
|
||||
self._sync_attempt_deadlines = {
|
||||
key: value
|
||||
for key, value in self._sync_attempt_deadlines.items()
|
||||
if key[0] in states and key[1] in states and value[1] > now
|
||||
}
|
||||
|
||||
if len(states) < 2:
|
||||
with self._lock:
|
||||
self._degraded = False
|
||||
self._failing_since = {}
|
||||
self._all_clear_since = None
|
||||
self._restore_suppressed_members(reason="bridge_has_fewer_than_two_members")
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
suppressed_snapshot = set(self._suppressed_members)
|
||||
settling_snapshot = {iface for iface, deadline in self._settle_deadlines.items() if deadline > now}
|
||||
|
||||
failing_members = sorted(
|
||||
ifname
|
||||
for ifname, state in states.items()
|
||||
if ifname not in suppressed_snapshot and ifname not in settling_snapshot and not state.link_ready
|
||||
)
|
||||
changed_members = sorted(
|
||||
ifname
|
||||
for ifname in states
|
||||
if self._config_changed(ifname, states, previous_states)
|
||||
)
|
||||
transition_seeds: list[str] = list(changed_members)
|
||||
if reason == "netlink_event" and source_ifname is not None and source_ifname in states:
|
||||
transition_seeds.append(source_ifname)
|
||||
if transition_seeds:
|
||||
self._mark_config_changes(transition_seeds, now)
|
||||
|
||||
if failing_members:
|
||||
config_source = self._find_config_sync_source(states, previous_states, preferred_ifname=source_ifname)
|
||||
if config_source is not None and self._has_config_mismatch(config_source, states):
|
||||
with self._lock:
|
||||
self._failing_since = {}
|
||||
self._sync_member_configuration(config_source, states)
|
||||
return
|
||||
|
||||
transition_members = sorted(
|
||||
{
|
||||
*changed_members,
|
||||
*( [source_ifname] if source_ifname is not None else [] ),
|
||||
*failing_members,
|
||||
}
|
||||
)
|
||||
if self._config_transition_active(transition_members, now):
|
||||
with self._lock:
|
||||
self._failing_since = {}
|
||||
self._set_last_action(
|
||||
f"waiting for config transition to settle on {transition_members} before suppressing"
|
||||
)
|
||||
return
|
||||
|
||||
matured_failing_members = self._track_failing_members(failing_members, now)
|
||||
if not matured_failing_members:
|
||||
self._set_last_action(f"waiting before suppressing transient failures on {failing_members}")
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._degraded = True
|
||||
self._all_clear_since = None
|
||||
self._suppress_other_members(states, matured_failing_members)
|
||||
return
|
||||
|
||||
if suppressed_snapshot:
|
||||
with self._lock:
|
||||
self._failing_since = {}
|
||||
should_restore = False
|
||||
waiting_for_restore = False
|
||||
with self._lock:
|
||||
self._degraded = True
|
||||
if self._all_clear_since is None:
|
||||
self._all_clear_since = now
|
||||
should_restore = now - self._all_clear_since >= self.recovery_holdoff_seconds
|
||||
if not should_restore:
|
||||
waiting_for_restore = True
|
||||
|
||||
if waiting_for_restore:
|
||||
self._set_last_action("waiting before restoring suppressed members")
|
||||
|
||||
if should_restore:
|
||||
self._restore_suppressed_members(reason=reason)
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._degraded = False
|
||||
self._failing_since = {}
|
||||
self._all_clear_since = None
|
||||
|
||||
config_source = self._find_config_sync_source(states, previous_states, preferred_ifname=source_ifname)
|
||||
if config_source is not None and self._has_config_mismatch(config_source, states):
|
||||
self._sync_member_configuration(config_source, states)
|
||||
return
|
||||
|
||||
self._set_last_action(f"no_restore_needed ({reason})")
|
||||
|
||||
def _pick_source_interface(self, messages: list[dict[str, Any]]) -> Optional[str]:
|
||||
"""Pick the most relevant bridge member from a batch of netlink messages."""
|
||||
members = set(get_bridge_ports_once(self.bridge_name))
|
||||
source_ifname: Optional[str] = None
|
||||
|
||||
for message in messages:
|
||||
attrs = dict(message.get("attrs", []))
|
||||
ifname = attrs.get("IFLA_IFNAME")
|
||||
if ifname in members:
|
||||
source_ifname = ifname
|
||||
|
||||
return source_ifname
|
||||
|
||||
def _is_source_eligible(self, ifname: str, states: dict[str, MemberLinkState]) -> bool:
|
||||
"""Return whether this member should be trusted as the configuration source."""
|
||||
with self._lock:
|
||||
ignored = self._managed_event_deadlines.get(ifname, 0.0) > time.time()
|
||||
suppressed = ifname in self._suppressed_members
|
||||
if ignored or suppressed:
|
||||
return False
|
||||
|
||||
state = states.get(ifname)
|
||||
return state is not None and state.link_ready
|
||||
|
||||
def _find_config_sync_source(
|
||||
self,
|
||||
states: dict[str, MemberLinkState],
|
||||
previous_states: dict[str, MemberLinkState],
|
||||
preferred_ifname: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Pick a healthy member whose configuration should be mirrored to siblings."""
|
||||
changed_candidates = [
|
||||
ifname
|
||||
for ifname in sorted(states)
|
||||
if self._config_changed(ifname, states, previous_states)
|
||||
]
|
||||
if preferred_ifname in changed_candidates:
|
||||
changed_candidates.remove(preferred_ifname)
|
||||
changed_candidates.insert(0, preferred_ifname)
|
||||
|
||||
if changed_candidates:
|
||||
for ifname in changed_candidates:
|
||||
if self._is_changed_source_eligible(ifname, states):
|
||||
return ifname
|
||||
self._set_last_action(
|
||||
f"waiting for changed configuration on {changed_candidates} to settle before syncing"
|
||||
)
|
||||
return None
|
||||
|
||||
candidates: list[str] = []
|
||||
if preferred_ifname:
|
||||
candidates.append(preferred_ifname)
|
||||
candidates.extend(ifname for ifname in sorted(states) if ifname != preferred_ifname)
|
||||
|
||||
seen: set[str] = set()
|
||||
for ifname in candidates:
|
||||
if ifname in seen:
|
||||
continue
|
||||
seen.add(ifname)
|
||||
if self._is_source_eligible(ifname, states):
|
||||
return ifname
|
||||
|
||||
return None
|
||||
|
||||
def _is_changed_source_eligible(self, ifname: str, states: dict[str, MemberLinkState]) -> bool:
|
||||
"""Allow a changed member to drive sync even while the link is transiently renegotiating."""
|
||||
with self._lock:
|
||||
ignored = self._managed_event_deadlines.get(ifname, 0.0) > time.time()
|
||||
suppressed = ifname in self._suppressed_members
|
||||
if ignored or suppressed:
|
||||
return False
|
||||
|
||||
state = states.get(ifname)
|
||||
if state is None:
|
||||
return False
|
||||
if state.link_ready:
|
||||
return True
|
||||
return self._state_has_usable_config(state)
|
||||
|
||||
def _config_changed(
|
||||
self,
|
||||
ifname: str,
|
||||
states: dict[str, MemberLinkState],
|
||||
previous_states: dict[str, MemberLinkState],
|
||||
) -> bool:
|
||||
"""Return whether this member's MTU or link profile changed since the last snapshot."""
|
||||
current = states.get(ifname)
|
||||
previous = previous_states.get(ifname)
|
||||
if current is None or previous is None:
|
||||
return False
|
||||
|
||||
return current.mtu != previous.mtu or self._profiles_differ(
|
||||
current.ethernet_profile,
|
||||
previous.ethernet_profile,
|
||||
)
|
||||
|
||||
def _state_has_usable_config(self, state: MemberLinkState) -> bool:
|
||||
"""Return whether this snapshot contains configuration that can be mirrored."""
|
||||
if state.mtu is not None:
|
||||
return True
|
||||
|
||||
return self._partial_profile_is_usable(state.ethernet_profile)
|
||||
|
||||
def _has_config_mismatch(self, source_ifname: str, states: dict[str, MemberLinkState]) -> bool:
|
||||
"""Return whether any sibling differs from the chosen source configuration."""
|
||||
source = states.get(source_ifname)
|
||||
if source is None:
|
||||
return False
|
||||
|
||||
for target_ifname, target in states.items():
|
||||
if target_ifname == source_ifname:
|
||||
continue
|
||||
if source.mtu is not None and target.mtu is not None and source.mtu != target.mtu:
|
||||
return True
|
||||
if source.ethernet_profile is not None and source.ethernet_profile != target.ethernet_profile:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _track_failing_members(self, failing_members: list[str], now: float) -> list[str]:
|
||||
"""Record first-seen timestamps and return failures that exceeded the holdoff."""
|
||||
with self._lock:
|
||||
self._degraded = True
|
||||
tracked = {
|
||||
ifname: self._failing_since.get(ifname, now)
|
||||
for ifname in failing_members
|
||||
}
|
||||
self._failing_since = tracked
|
||||
matured = [
|
||||
ifname
|
||||
for ifname, first_seen in tracked.items()
|
||||
if now - first_seen >= settings.bridge_link_state_failure_holdoff_seconds
|
||||
]
|
||||
|
||||
return sorted(matured)
|
||||
|
||||
def _mark_config_changes(self, ifnames: list[str], now: float) -> None:
|
||||
"""Keep short-lived link flaps from config changes from being treated as failures."""
|
||||
holdoff = max(
|
||||
settings.bridge_link_state_failure_holdoff_seconds * 2,
|
||||
self.recovery_holdoff_seconds * 2,
|
||||
1.5,
|
||||
)
|
||||
with self._lock:
|
||||
for ifname in ifnames:
|
||||
existing_deadline = self._config_change_deadlines.get(ifname, 0.0)
|
||||
if existing_deadline > now:
|
||||
continue
|
||||
self._config_change_deadlines[ifname] = now + holdoff
|
||||
|
||||
def _config_transition_active(self, ifnames: list[str], now: float) -> bool:
|
||||
"""Return whether any listed member is still inside the config-change grace window."""
|
||||
with self._lock:
|
||||
return any(self._config_change_deadlines.get(ifname, 0.0) > now for ifname in ifnames)
|
||||
|
||||
def _next_wait_timeout(self) -> Optional[float]:
|
||||
"""Return how long the watcher may sleep before the next restore deadline."""
|
||||
with self._lock:
|
||||
if self._suppressed_members and self._all_clear_since is not None:
|
||||
deadline = self._all_clear_since + self.recovery_holdoff_seconds
|
||||
return max(0.0, min(deadline - time.time(), settings.bridge_link_state_degraded_recheck_seconds))
|
||||
if self._failing_since:
|
||||
earliest_deadline = min(
|
||||
first_seen + settings.bridge_link_state_failure_holdoff_seconds
|
||||
for first_seen in self._failing_since.values()
|
||||
)
|
||||
return max(0.0, min(earliest_deadline - time.time(), settings.bridge_link_state_degraded_recheck_seconds))
|
||||
if self._degraded:
|
||||
return settings.bridge_link_state_degraded_recheck_seconds
|
||||
return None
|
||||
|
||||
def _wake_thread(self) -> None:
|
||||
"""Wake the event loop from another thread."""
|
||||
try:
|
||||
os.write(self._wake_w, b"\x00")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _drain_wake_pipe(self) -> None:
|
||||
"""Drain pending wake-up bytes from the control pipe."""
|
||||
try:
|
||||
while os.read(self._wake_r, 4096):
|
||||
pass
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _read_member_state(
|
||||
self,
|
||||
ifname: str,
|
||||
previous_state: Optional[MemberLinkState] = None,
|
||||
) -> MemberLinkState:
|
||||
admin_up = read_interface_admin_up(ifname)
|
||||
carrier_up = read_interface_carrier(ifname)
|
||||
operstate = read_interface_operstate(ifname)
|
||||
link_ready = self._link_looks_ready(admin_up, carrier_up, operstate)
|
||||
|
||||
return MemberLinkState(
|
||||
ifname=ifname,
|
||||
admin_up=admin_up,
|
||||
carrier_up=carrier_up,
|
||||
operstate=operstate,
|
||||
mtu=read_interface_mtu(ifname),
|
||||
ethernet_profile=self._read_ethernet_profile(
|
||||
ifname,
|
||||
previous_state=previous_state,
|
||||
link_ready=link_ready,
|
||||
),
|
||||
)
|
||||
|
||||
def _read_ethernet_profile(
|
||||
self,
|
||||
ifname: str,
|
||||
previous_state: Optional[MemberLinkState] = None,
|
||||
link_ready: Optional[bool] = None,
|
||||
) -> Optional[EthernetProfile]:
|
||||
"""Read ethtool speed/duplex/autoneg for one interface if supported."""
|
||||
profile = read_interface_ethernet_profile(ifname)
|
||||
if profile is None:
|
||||
current_profile = None
|
||||
else:
|
||||
current_profile = EthernetProfile(
|
||||
speed_mbps=profile.get("speed_mbps"),
|
||||
duplex=profile.get("duplex"),
|
||||
autoneg=profile.get("autoneg"),
|
||||
)
|
||||
|
||||
if self._should_keep_previous_profile(ifname, current_profile, previous_state, link_ready):
|
||||
return previous_state.ethernet_profile if previous_state is not None else None
|
||||
|
||||
previous_profile = previous_state.ethernet_profile if previous_state is not None else None
|
||||
return self._materialize_profile(current_profile, previous_profile)
|
||||
|
||||
def _should_keep_previous_profile(
|
||||
self,
|
||||
ifname: str,
|
||||
current_profile: Optional[EthernetProfile],
|
||||
previous_state: Optional[MemberLinkState],
|
||||
link_ready: Optional[bool],
|
||||
) -> bool:
|
||||
"""Keep the previous non-null profile during transient renegotiation windows."""
|
||||
if previous_state is None or previous_state.ethernet_profile is None:
|
||||
return False
|
||||
if current_profile is not None and self._partial_profile_is_usable(current_profile):
|
||||
return False
|
||||
if link_ready is True:
|
||||
return False
|
||||
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
settling = self._settle_deadlines.get(ifname, 0.0) > now
|
||||
managed = self._managed_event_deadlines.get(ifname, 0.0) > now
|
||||
|
||||
return settling or managed or link_ready is False
|
||||
|
||||
def _link_looks_ready(
|
||||
self,
|
||||
admin_up: Optional[bool],
|
||||
carrier_up: Optional[bool],
|
||||
operstate: Optional[str],
|
||||
) -> bool:
|
||||
"""Evaluate link health from sysfs fields before the MemberLinkState is built."""
|
||||
if admin_up is not True:
|
||||
return False
|
||||
if carrier_up is False:
|
||||
return False
|
||||
if operstate in {"down", "lowerlayerdown", "notpresent"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _profiles_differ(
|
||||
self,
|
||||
current_profile: Optional[EthernetProfile],
|
||||
previous_profile: Optional[EthernetProfile],
|
||||
) -> bool:
|
||||
"""Ignore transient non-null to null drops when detecting config changes."""
|
||||
if current_profile is None:
|
||||
return False
|
||||
if previous_profile is None:
|
||||
return True
|
||||
return current_profile != previous_profile
|
||||
|
||||
def _partial_profile_is_usable(self, profile: Optional[EthernetProfile]) -> bool:
|
||||
"""Return whether a profile contains enough data to drive synchronization."""
|
||||
if profile is None:
|
||||
return False
|
||||
if profile.autoneg is None:
|
||||
return False
|
||||
return profile.speed_mbps is not None and profile.duplex is not None
|
||||
|
||||
def _materialize_profile(
|
||||
self,
|
||||
current_profile: Optional[EthernetProfile],
|
||||
previous_profile: Optional[EthernetProfile],
|
||||
) -> Optional[EthernetProfile]:
|
||||
"""Fill transiently missing profile fields from the last stable snapshot."""
|
||||
if current_profile is None:
|
||||
return previous_profile
|
||||
if previous_profile is None:
|
||||
return current_profile
|
||||
if self._partial_profile_is_usable(current_profile):
|
||||
return current_profile
|
||||
|
||||
return EthernetProfile(
|
||||
speed_mbps=current_profile.speed_mbps if current_profile.speed_mbps is not None else previous_profile.speed_mbps,
|
||||
duplex=current_profile.duplex if current_profile.duplex is not None else previous_profile.duplex,
|
||||
autoneg=current_profile.autoneg if current_profile.autoneg is not None else previous_profile.autoneg,
|
||||
)
|
||||
|
||||
def _sync_member_configuration(self, source_ifname: str, states: dict[str, MemberLinkState]) -> None:
|
||||
"""Mirror MTU and ethtool link settings from one healthy member to its siblings."""
|
||||
source = states[source_ifname]
|
||||
changes: list[str] = []
|
||||
|
||||
for target_ifname, target in sorted(states.items()):
|
||||
if target_ifname == source_ifname:
|
||||
continue
|
||||
|
||||
mtu_change = self._sync_member_mtu(source_ifname, source, target_ifname, target)
|
||||
profile_change = self._sync_member_ethernet_profile(source_ifname, source, target_ifname, target)
|
||||
if mtu_change:
|
||||
changes.append(mtu_change)
|
||||
if profile_change:
|
||||
changes.append(profile_change)
|
||||
|
||||
if changes:
|
||||
self._set_last_action(f"synchronized from {source_ifname}: {'; '.join(changes)}")
|
||||
else:
|
||||
self._set_last_action(f"no configuration mismatch detected after event on {source_ifname}")
|
||||
|
||||
def _sync_member_mtu(
|
||||
self,
|
||||
source_ifname: str,
|
||||
source: MemberLinkState,
|
||||
target_ifname: str,
|
||||
target: MemberLinkState,
|
||||
) -> Optional[str]:
|
||||
"""Mirror MTU when the source member differs from the target."""
|
||||
if source.mtu is None or target.mtu is None or source.mtu == target.mtu:
|
||||
return None
|
||||
|
||||
with IPRoute() as ipr:
|
||||
indices = ipr.link_lookup(ifname=target_ifname)
|
||||
if not indices:
|
||||
raise RuntimeError(f"Interface {target_ifname} not found while synchronizing MTU")
|
||||
ipr.link("set", index=indices[0], mtu=source.mtu)
|
||||
|
||||
self._mark_managed_change(target_ifname)
|
||||
logger.info(
|
||||
"Synchronized MTU from %s to %s on bridge=%s: %s",
|
||||
source_ifname,
|
||||
target_ifname,
|
||||
self.bridge_name,
|
||||
source.mtu,
|
||||
)
|
||||
return f"{target_ifname} mtu={source.mtu}"
|
||||
|
||||
def _sync_member_ethernet_profile(
|
||||
self,
|
||||
source_ifname: str,
|
||||
source: MemberLinkState,
|
||||
target_ifname: str,
|
||||
target: MemberLinkState,
|
||||
) -> Optional[str]:
|
||||
"""Mirror ethtool speed/duplex/autoneg from the source member to the target."""
|
||||
source_profile = source.ethernet_profile
|
||||
target_profile = target.ethernet_profile
|
||||
if source_profile is None or target_profile is None or source_profile == target_profile:
|
||||
return None
|
||||
if not self._partial_profile_is_usable(source_profile):
|
||||
return None
|
||||
if not self._should_attempt_sync(source_ifname, target_ifname, source_profile):
|
||||
return None
|
||||
|
||||
change_summary: Optional[str] = None
|
||||
commands: list[tuple[list[str], str]] = []
|
||||
if source_profile.autoneg is True:
|
||||
# A remote peer can pull this port down to a lower negotiated speed while autoneg
|
||||
# stays enabled locally. Mirror that effective mode while keeping autoneg enabled
|
||||
# on the sibling so its far-end peer can still negotiate successfully.
|
||||
commands.append(
|
||||
(
|
||||
[
|
||||
_ETHTOOL_BIN,
|
||||
"-s",
|
||||
target_ifname,
|
||||
"speed",
|
||||
str(source_profile.speed_mbps),
|
||||
"duplex",
|
||||
source_profile.duplex,
|
||||
"autoneg",
|
||||
"on",
|
||||
],
|
||||
(
|
||||
f"{target_ifname} link={source_profile.speed_mbps}Mb/"
|
||||
f"{source_profile.duplex}/autoneg-on"
|
||||
),
|
||||
)
|
||||
)
|
||||
elif source_profile.autoneg is False and source_profile.speed_mbps is not None and source_profile.duplex is not None:
|
||||
# Do not force autoneg off on sibling ports. The far-end peer on that segment may
|
||||
# still rely on autoneg, and forcing a fixed mode here can leave the link down.
|
||||
commands.append(
|
||||
(
|
||||
[
|
||||
_ETHTOOL_BIN,
|
||||
"-s",
|
||||
target_ifname,
|
||||
"speed",
|
||||
str(source_profile.speed_mbps),
|
||||
"duplex",
|
||||
source_profile.duplex,
|
||||
"autoneg",
|
||||
"on",
|
||||
],
|
||||
(
|
||||
f"{target_ifname} link={source_profile.speed_mbps}Mb/"
|
||||
f"{source_profile.duplex}/autoneg-on-safe"
|
||||
),
|
||||
)
|
||||
)
|
||||
commands.append(
|
||||
(
|
||||
[_ETHTOOL_BIN, "-s", target_ifname, "autoneg", "on"],
|
||||
f"{target_ifname} link=autoneg-on-safe",
|
||||
)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for cmd, summary in commands:
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
change_summary = summary
|
||||
break
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
last_error = exc
|
||||
logger.debug(
|
||||
"Failed to synchronize ethtool profile from %s to %s on bridge=%s with %s: %s",
|
||||
source_ifname,
|
||||
target_ifname,
|
||||
self.bridge_name,
|
||||
cmd,
|
||||
exc,
|
||||
)
|
||||
|
||||
if change_summary is None:
|
||||
if last_error is not None:
|
||||
self._record_sync_attempt(source_ifname, target_ifname, source_profile)
|
||||
self._set_last_action(
|
||||
f"failed to sync link profile from {source_ifname} to {target_ifname}: {last_error}"
|
||||
)
|
||||
return None
|
||||
|
||||
self._record_sync_attempt(source_ifname, target_ifname, source_profile)
|
||||
self._mark_managed_change(target_ifname)
|
||||
logger.info(
|
||||
"Synchronized ethtool profile from %s to %s on bridge=%s: %s",
|
||||
source_ifname,
|
||||
target_ifname,
|
||||
self.bridge_name,
|
||||
source_profile,
|
||||
)
|
||||
return change_summary
|
||||
|
||||
def _sync_attempt_signature(self, source_profile: EthernetProfile) -> str:
|
||||
"""Serialize the desired mirrored link state for retry deduplication."""
|
||||
return f"{source_profile.speed_mbps}:{source_profile.duplex}:{source_profile.autoneg}"
|
||||
|
||||
def _should_attempt_sync(
|
||||
self,
|
||||
source_ifname: str,
|
||||
target_ifname: str,
|
||||
source_profile: EthernetProfile,
|
||||
) -> bool:
|
||||
"""Avoid replaying the same sync on every degraded-state recheck."""
|
||||
signature = self._sync_attempt_signature(source_profile)
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
cached = self._sync_attempt_deadlines.get((source_ifname, target_ifname))
|
||||
if cached is None:
|
||||
return True
|
||||
cached_signature, deadline = cached
|
||||
return cached_signature != signature or deadline <= now
|
||||
|
||||
def _record_sync_attempt(
|
||||
self,
|
||||
source_ifname: str,
|
||||
target_ifname: str,
|
||||
source_profile: EthernetProfile,
|
||||
) -> None:
|
||||
"""Rate-limit repeated sync attempts for the same desired link profile."""
|
||||
signature = self._sync_attempt_signature(source_profile)
|
||||
cooldown = max(
|
||||
settings.bridge_link_state_degraded_recheck_seconds * 4,
|
||||
self.recovery_holdoff_seconds,
|
||||
)
|
||||
with self._lock:
|
||||
self._sync_attempt_deadlines[(source_ifname, target_ifname)] = (signature, time.time() + cooldown)
|
||||
|
||||
def _suppress_other_members(self, states: dict[str, MemberLinkState], failing_members: list[str]) -> None:
|
||||
desired_suppressed = set(states) - set(failing_members)
|
||||
|
||||
with self._lock:
|
||||
current_suppressed = dict(self._suppressed_members)
|
||||
|
||||
next_suppressed: dict[str, bool] = {}
|
||||
changed_members: list[str] = []
|
||||
|
||||
for ifname in sorted(desired_suppressed):
|
||||
restore_up = current_suppressed.get(ifname, states[ifname].admin_up is True)
|
||||
if ifname not in current_suppressed and states[ifname].admin_up is True:
|
||||
self._set_interface_admin_state(ifname, target_up=False)
|
||||
changed_members.append(ifname)
|
||||
next_suppressed[ifname] = restore_up
|
||||
|
||||
with self._lock:
|
||||
self._suppressed_members = next_suppressed
|
||||
action = (
|
||||
f"suppressed {changed_members} because failing members={failing_members}"
|
||||
if changed_members
|
||||
else f"holding suppressed members because failing members={failing_members}"
|
||||
)
|
||||
self._set_last_action(action)
|
||||
|
||||
def _restore_suppressed_members(self, reason: str) -> None:
|
||||
with self._lock:
|
||||
suppressed = dict(self._suppressed_members)
|
||||
|
||||
if not suppressed:
|
||||
self._set_last_action(f"no_restore_needed ({reason})")
|
||||
return
|
||||
|
||||
restored_members: list[str] = []
|
||||
for ifname, restore_up in sorted(suppressed.items()):
|
||||
if not check_interface_exists(ifname):
|
||||
continue
|
||||
if restore_up:
|
||||
self._set_interface_admin_state(ifname, target_up=True)
|
||||
restored_members.append(ifname)
|
||||
|
||||
with self._lock:
|
||||
self._suppressed_members = {}
|
||||
self._all_clear_since = None
|
||||
self._set_last_action(f"restored {restored_members} ({reason})")
|
||||
|
||||
def _set_interface_admin_state(self, ifname: str, target_up: bool) -> None:
|
||||
if not check_interface_exists(ifname):
|
||||
raise RuntimeError(f"Interface {ifname} disappeared while propagating link state")
|
||||
|
||||
state_name = "up" if target_up else "down"
|
||||
with IPRoute() as ipr:
|
||||
indices = ipr.link_lookup(ifname=ifname)
|
||||
if not indices:
|
||||
raise RuntimeError(f"Interface {ifname} not found while propagating link state")
|
||||
ipr.link("set", index=indices[0], state=state_name)
|
||||
|
||||
if target_up:
|
||||
with self._lock:
|
||||
self._settle_deadlines[ifname] = time.time() + self.recovery_holdoff_seconds
|
||||
self._mark_managed_change(ifname)
|
||||
|
||||
def _mark_managed_change(self, ifname: str) -> None:
|
||||
"""Ignore immediate follow-up netlink events caused by our own changes."""
|
||||
with self._lock:
|
||||
self._managed_event_deadlines[ifname] = time.time() + self.recovery_holdoff_seconds
|
||||
|
||||
def _set_last_action(self, action: str) -> None:
|
||||
"""Update the watcher action text."""
|
||||
changed = False
|
||||
with self._lock:
|
||||
changed = action != self._last_action
|
||||
self._last_action = action
|
||||
|
||||
if changed:
|
||||
self._publish_network_state_update()
|
||||
|
||||
def _publish_network_state_update(self) -> None:
|
||||
"""Publish a network snapshot after a meaningful watcher state change."""
|
||||
try:
|
||||
from src.api.network_api import publish_network_state_update
|
||||
|
||||
publish_network_state_update(f"bridge_watcher:{self.bridge_name}")
|
||||
except Exception:
|
||||
logger.exception("Failed to publish network state for bridge=%s", self.bridge_name)
|
||||
|
||||
|
||||
class BridgeLinkStateManager:
|
||||
"""Track bridge link-state watchers keyed by bridge name."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._watchers: dict[str, BridgeLinkStateWatcher] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def enable(self, bridge_name: str, recovery_holdoff_seconds: Optional[float] = None) -> dict[str, Any]:
|
||||
"""Start or replace the watcher for the given bridge."""
|
||||
normalized_holdoff = recovery_holdoff_seconds or settings.bridge_link_state_recovery_holdoff_seconds
|
||||
with self._lock:
|
||||
old_watcher = self._watchers.pop(bridge_name, None)
|
||||
|
||||
if old_watcher is not None:
|
||||
old_watcher.stop()
|
||||
|
||||
watcher = BridgeLinkStateWatcher(bridge_name, normalized_holdoff)
|
||||
watcher.start()
|
||||
|
||||
with self._lock:
|
||||
self._watchers[bridge_name] = watcher
|
||||
|
||||
return watcher.status()
|
||||
|
||||
def disable(self, bridge_name: str) -> dict[str, Any]:
|
||||
"""Stop the watcher for one bridge."""
|
||||
with self._lock:
|
||||
watcher = self._watchers.pop(bridge_name, None)
|
||||
|
||||
if watcher is None:
|
||||
return {
|
||||
"bridge": bridge_name,
|
||||
"active": False,
|
||||
"message": "watcher not enabled",
|
||||
}
|
||||
|
||||
watcher.stop()
|
||||
status = watcher.status()
|
||||
status["active"] = False
|
||||
return status
|
||||
|
||||
def get_status(self, bridge_name: str) -> Optional[dict[str, Any]]:
|
||||
"""Return the current watcher status for one bridge, if present."""
|
||||
with self._lock:
|
||||
watcher = self._watchers.get(bridge_name)
|
||||
return watcher.status() if watcher is not None else None
|
||||
|
||||
def list_statuses(self) -> list[dict[str, Any]]:
|
||||
"""Return the current status for all bridge watchers."""
|
||||
with self._lock:
|
||||
watchers = list(self._watchers.values())
|
||||
return [watcher.status() for watcher in sorted(watchers, key=lambda item: item.bridge_name)]
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop all running watchers."""
|
||||
with self._lock:
|
||||
watchers = list(self._watchers.values())
|
||||
self._watchers.clear()
|
||||
|
||||
for watcher in watchers:
|
||||
watcher.stop()
|
||||
|
||||
|
||||
bridge_link_state_manager = BridgeLinkStateManager()
|
||||
352
backend/src/utilities/bridge_telemetry.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Manage the eBPF/tc telemetry subprocess used for bridge packet capture and verdict events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping, Optional
|
||||
|
||||
from src.config import settings
|
||||
from src.utilities.packet_tracker import packet_tracker
|
||||
|
||||
logger = logging.getLogger("bridge_telemetry")
|
||||
|
||||
|
||||
class BridgeTelemetryManager:
|
||||
"""Run one tc/eBPF collector for the currently sniffed bridge interfaces."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._interfaces: set[str] = set()
|
||||
self._session_ids_by_interface: dict[str, tuple[str, ...]] = {}
|
||||
self._benchmark_by_interface: dict[str, bool] = {}
|
||||
self._process: Optional[subprocess.Popen[str]] = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._event_queue: queue.Queue = queue.Queue(maxsize=settings.bridge_telemetry_event_queue_maxsize)
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._event_worker_loop,
|
||||
daemon=True,
|
||||
name="bridge-telemetry-worker",
|
||||
)
|
||||
self._worker_thread.start()
|
||||
self._dropped_events = 0
|
||||
self._dropped_raw_payloads = 0
|
||||
self._benchmark_events = 0
|
||||
self._benchmark_raw_payloads = 0
|
||||
self._last_drop_log_at = 0.0
|
||||
self._suppressed_collector_messages = 0
|
||||
self._last_collector_warning_at = 0.0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def update_sessions(
|
||||
self,
|
||||
session_interfaces: Mapping[str, Iterable[str]],
|
||||
benchmark_session_ids: Optional[set[str]] = None,
|
||||
) -> None:
|
||||
"""Restart the collector when the active bridge interface set changes."""
|
||||
benchmark_session_ids = benchmark_session_ids or set()
|
||||
normalized: dict[str, set[str]] = {}
|
||||
for session_id, interfaces in session_interfaces.items():
|
||||
if not session_id:
|
||||
continue
|
||||
iface_set = {iface.strip() for iface in interfaces if iface and iface.strip()}
|
||||
if iface_set:
|
||||
normalized[session_id] = iface_set
|
||||
|
||||
normalized_interfaces = sorted({iface for ifaces in normalized.values() for iface in ifaces})
|
||||
session_ids_by_interface = {
|
||||
iface: tuple(sorted(session_id for session_id, ifaces in normalized.items() if iface in ifaces))
|
||||
for iface in normalized_interfaces
|
||||
}
|
||||
benchmark_by_interface = {
|
||||
iface: bool(session_ids_by_interface.get(iface))
|
||||
and all(session_id in benchmark_session_ids for session_id in session_ids_by_interface.get(iface, ()))
|
||||
for iface in normalized_interfaces
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
interfaces_changed = set(normalized_interfaces) != self._interfaces
|
||||
self._interfaces = set(normalized_interfaces)
|
||||
self._session_ids_by_interface = session_ids_by_interface
|
||||
self._benchmark_by_interface = benchmark_by_interface
|
||||
if not interfaces_changed:
|
||||
return
|
||||
self._restart_locked()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the collector process and reader thread."""
|
||||
with self._lock:
|
||||
self._interfaces = set()
|
||||
self._stop_process_locked()
|
||||
|
||||
def _restart_locked(self) -> None:
|
||||
self._stop_process_locked()
|
||||
if not self._interfaces:
|
||||
return
|
||||
|
||||
helper = Path(__file__).with_name("ebpf_bridge_events.py")
|
||||
python_bin = sys.executable or "python3"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
backend_root = str(helper.parents[2])
|
||||
existing_pythonpath = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = backend_root if not existing_pythonpath else f"{backend_root}:{existing_pythonpath}"
|
||||
|
||||
cmd = [
|
||||
python_bin,
|
||||
str(helper),
|
||||
"--ifaces",
|
||||
",".join(sorted(self._interfaces)),
|
||||
"--build-dir",
|
||||
settings.bridge_bpf_build_dir,
|
||||
"--raw-sample-every",
|
||||
str(settings.bridge_telemetry_raw_sample_every),
|
||||
"--meta-sample-every",
|
||||
str(settings.bridge_telemetry_meta_sample_every),
|
||||
"--ingress-pages",
|
||||
str(settings.bridge_telemetry_ingress_perf_pages),
|
||||
"--meta-pages",
|
||||
str(settings.bridge_telemetry_meta_perf_pages),
|
||||
]
|
||||
logger.info(
|
||||
"Starting bridge telemetry collector for interfaces=%s raw_sample_every=%s meta_sample_every=%s",
|
||||
sorted(self._interfaces),
|
||||
settings.bridge_telemetry_raw_sample_every,
|
||||
settings.bridge_telemetry_meta_sample_every,
|
||||
)
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to start bridge telemetry collector")
|
||||
self._process = None
|
||||
return
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_loop,
|
||||
args=(self._process,),
|
||||
daemon=True,
|
||||
name="bridge-telemetry-reader",
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
def _stop_process_locked(self) -> None:
|
||||
process = self._process
|
||||
reader = self._reader_thread
|
||||
self._process = None
|
||||
self._reader_thread = None
|
||||
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
process.send_signal(signal.SIGTERM)
|
||||
process.wait(timeout=settings.telemetry_process_stop_timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
except Exception:
|
||||
logger.exception("Failed to stop bridge telemetry collector cleanly")
|
||||
|
||||
if reader is not None and reader.is_alive():
|
||||
reader.join(timeout=settings.telemetry_reader_join_timeout_seconds)
|
||||
|
||||
def _handle_ingress_packet(self, event: dict[str, object]) -> None:
|
||||
raw_b64 = event.pop("raw_b64", None)
|
||||
if not isinstance(raw_b64, str) or not raw_b64:
|
||||
return
|
||||
|
||||
try:
|
||||
packet_bytes = base64.b64decode(raw_b64)
|
||||
except Exception:
|
||||
logger.exception("Failed to decode ingress raw packet")
|
||||
return
|
||||
|
||||
capture_metadata = {
|
||||
"capture_source": "tc_ingress_raw",
|
||||
"packet_id": event.get("packet_id"),
|
||||
"skb_mark": event.get("skb_mark"),
|
||||
"capture_mode": "tc_ingress",
|
||||
}
|
||||
capture_iface = str(event.get("iface") or "")
|
||||
capture_session_id: Optional[str] = None
|
||||
with self._lock:
|
||||
session_ids = self._session_ids_by_interface.get(capture_iface, ())
|
||||
if session_ids:
|
||||
capture_session_id = session_ids[0]
|
||||
|
||||
try:
|
||||
from src.network_sniffer import parse_packet_bytes
|
||||
|
||||
parse_packet_bytes(
|
||||
packet_bytes,
|
||||
capture_iface,
|
||||
capture_metadata=capture_metadata,
|
||||
capture_session_id=capture_session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to process ingress raw packet event")
|
||||
|
||||
def _event_worker_loop(self) -> None:
|
||||
while True:
|
||||
event = self._event_queue.get()
|
||||
try:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
iface = str(event.get("iface") or "")
|
||||
with self._lock:
|
||||
benchmark_mode = bool(self._benchmark_by_interface.get(iface))
|
||||
if benchmark_mode:
|
||||
raw_b64 = event.pop("raw_b64", None)
|
||||
with self._lock:
|
||||
self._benchmark_events += 1
|
||||
if raw_b64:
|
||||
self._benchmark_raw_payloads += 1
|
||||
continue
|
||||
|
||||
if event.get("event_type") == "ingress":
|
||||
self._handle_ingress_packet(event)
|
||||
|
||||
try:
|
||||
packet_tracker.observe_telemetry(event)
|
||||
except Exception:
|
||||
logger.exception("Failed to process telemetry event: %s", event)
|
||||
finally:
|
||||
self._event_queue.task_done()
|
||||
|
||||
def _enqueue_event(self, event: dict[str, object]) -> None:
|
||||
try:
|
||||
self._event_queue.put_nowait(event)
|
||||
return
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
raw_b64 = event.pop("raw_b64", None)
|
||||
dropped_raw = isinstance(raw_b64, str) and bool(raw_b64)
|
||||
dropped_events, dropped_raw_payloads = self._drain_overloaded_queue()
|
||||
|
||||
try:
|
||||
self._event_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
with self._lock:
|
||||
self._dropped_events += dropped_events + 1
|
||||
self._dropped_raw_payloads += dropped_raw_payloads
|
||||
self._log_drop_summary()
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._dropped_events += dropped_events + 1
|
||||
self._dropped_raw_payloads += dropped_raw_payloads
|
||||
if dropped_raw:
|
||||
self._dropped_raw_payloads += 1
|
||||
self._log_drop_summary()
|
||||
|
||||
def _drain_overloaded_queue(self) -> tuple[int, int]:
|
||||
target_size = min(
|
||||
settings.bridge_telemetry_queue_recovery_size,
|
||||
max(settings.bridge_telemetry_event_queue_maxsize - 1, 0),
|
||||
)
|
||||
dropped_events = 0
|
||||
dropped_raw_payloads = 0
|
||||
while self._event_queue.qsize() > target_size:
|
||||
try:
|
||||
stale_event = self._event_queue.get_nowait()
|
||||
self._event_queue.task_done()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
dropped_events += 1
|
||||
if isinstance(stale_event, dict) and stale_event.get("raw_b64"):
|
||||
dropped_raw_payloads += 1
|
||||
|
||||
return dropped_events, dropped_raw_payloads
|
||||
|
||||
def _log_drop_summary(self) -> None:
|
||||
now_ts = time.time()
|
||||
if now_ts - self._last_drop_log_at < settings.bridge_telemetry_drop_log_interval_seconds:
|
||||
return
|
||||
|
||||
self._last_drop_log_at = now_ts
|
||||
with self._lock:
|
||||
dropped_events = self._dropped_events
|
||||
dropped_raw_payloads = self._dropped_raw_payloads
|
||||
queue_size = self._event_queue.qsize()
|
||||
|
||||
logger.warning(
|
||||
"Bridge telemetry is overloaded; queue=%s dropped_events=%s dropped_raw_payloads=%s",
|
||||
queue_size,
|
||||
dropped_events,
|
||||
dropped_raw_payloads,
|
||||
)
|
||||
|
||||
def _log_collector_message(self, text: str) -> None:
|
||||
lower_text = text.lower()
|
||||
is_loss_message = "lost" in lower_text and "sample" in lower_text
|
||||
if not is_loss_message:
|
||||
logger.info("bridge-telemetry: %s", text)
|
||||
return
|
||||
|
||||
now_ts = time.time()
|
||||
if now_ts - self._last_collector_warning_at < settings.bridge_telemetry_drop_log_interval_seconds:
|
||||
with self._lock:
|
||||
self._suppressed_collector_messages += 1
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
suppressed = self._suppressed_collector_messages
|
||||
self._suppressed_collector_messages = 0
|
||||
self._last_collector_warning_at = now_ts
|
||||
logger.warning("bridge-telemetry: %s (suppressed similar messages=%s)", text, suppressed)
|
||||
|
||||
def _read_loop(self, process: subprocess.Popen[str]) -> None:
|
||||
stdout = process.stdout
|
||||
if stdout is None:
|
||||
return
|
||||
|
||||
for line in stdout:
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
self._log_collector_message(text)
|
||||
continue
|
||||
|
||||
if "event_type" not in event:
|
||||
logger.info("bridge-telemetry: %s", event)
|
||||
continue
|
||||
|
||||
self._enqueue_event(event)
|
||||
|
||||
rc = process.poll()
|
||||
if rc not in (0, None):
|
||||
logger.warning("Bridge telemetry collector exited with code %s", rc)
|
||||
|
||||
def get_debug_snapshot(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
return {
|
||||
"interfaces": sorted(self._interfaces),
|
||||
"benchmark_by_interface": dict(self._benchmark_by_interface),
|
||||
"queue_size": self._event_queue.qsize(),
|
||||
"dropped_events": self._dropped_events,
|
||||
"dropped_raw_payloads": self._dropped_raw_payloads,
|
||||
"benchmark_events": self._benchmark_events,
|
||||
"benchmark_raw_payloads": self._benchmark_raw_payloads,
|
||||
}
|
||||
|
||||
|
||||
bridge_telemetry_manager = BridgeTelemetryManager()
|
||||
735
backend/src/utilities/ebpf_bridge_events.py
Normal file
@@ -0,0 +1,735 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit bridge ingress raw packets plus egress/drop telemetry via tc/eBPF."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import ctypes as ct
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from pyroute2 import IPRoute
|
||||
|
||||
try:
|
||||
from bcc import BPF # type: ignore
|
||||
except Exception as exc: # pragma: no cover - depends on host runtime
|
||||
dist_packages = [
|
||||
Path("/usr/lib/python3/dist-packages"),
|
||||
Path("/usr/lib64/python3/dist-packages"),
|
||||
]
|
||||
for candidate in dist_packages:
|
||||
candidate_str = str(candidate)
|
||||
if candidate.is_dir() and candidate_str not in sys.path:
|
||||
sys.path.append(candidate_str)
|
||||
try:
|
||||
from bcc import BPF # type: ignore
|
||||
except Exception:
|
||||
print(f"Failed to import python3-bpfcc: {exc}", file=sys.stderr, flush=True)
|
||||
raise
|
||||
|
||||
from src.utilities.packet_mark import packet_id_from_mark, verdict_from_mark
|
||||
|
||||
|
||||
EVENT_INGRESS = 1
|
||||
EVENT_EGRESS = 2
|
||||
EVENT_DROP = 3
|
||||
INGRESS_PARENT = "ffff:fff2"
|
||||
EGRESS_PARENT = "ffff:fff3"
|
||||
INGRESS_FILTER_HANDLE = ":20"
|
||||
EGRESS_FILTER_HANDLE = ":30"
|
||||
|
||||
IDENTITY_FIELDS = (
|
||||
"src_mac",
|
||||
"dst_mac",
|
||||
"eth_type_raw",
|
||||
"vlan_id",
|
||||
"src_ip",
|
||||
"dst_ip",
|
||||
"protocol_raw",
|
||||
"src_port",
|
||||
"dst_port",
|
||||
"length",
|
||||
)
|
||||
|
||||
BPF_SOURCE = r"""
|
||||
#include <uapi/linux/ptrace.h>
|
||||
#include <uapi/linux/pkt_cls.h>
|
||||
#include <linux/bpf.h>
|
||||
#include <linux/skbuff.h>
|
||||
#include <linux/netdevice.h>
|
||||
#include <linux/if_ether.h>
|
||||
#include <linux/ip.h>
|
||||
#include <linux/ipv6.h>
|
||||
#include <linux/in.h>
|
||||
#include <linux/tcp.h>
|
||||
#include <linux/udp.h>
|
||||
#include <linux/if_arp.h>
|
||||
|
||||
#define EVENT_INGRESS 1
|
||||
#define EVENT_EGRESS 2
|
||||
#define EVENT_DROP 3
|
||||
#define RAW_SAMPLE_EVERY __RAW_SAMPLE_EVERY__
|
||||
#define META_SAMPLE_EVERY __META_SAMPLE_EVERY__
|
||||
|
||||
struct vlan_hdr_t {
|
||||
__be16 h_vlan_TCI;
|
||||
__be16 h_vlan_encapsulated_proto;
|
||||
};
|
||||
|
||||
struct arp_eth_ipv4_t {
|
||||
__u8 sha[6];
|
||||
__u8 spa[4];
|
||||
__u8 tha[6];
|
||||
__u8 tpa[4];
|
||||
};
|
||||
|
||||
struct event_t {
|
||||
__u64 ts_ns;
|
||||
__u32 skb_mark;
|
||||
__u32 length;
|
||||
__u32 reason;
|
||||
__u32 ifindex;
|
||||
__u16 eth_type_raw;
|
||||
__u16 vlan_id;
|
||||
__u16 src_port;
|
||||
__u16 dst_port;
|
||||
__u32 protocol_raw;
|
||||
__u8 event_type;
|
||||
__u8 ip_version;
|
||||
__u8 reserved[2];
|
||||
unsigned char src_mac[6];
|
||||
unsigned char dst_mac[6];
|
||||
unsigned char src_ip[16];
|
||||
unsigned char dst_ip[16];
|
||||
};
|
||||
|
||||
BPF_PERF_OUTPUT(ingress_events);
|
||||
BPF_PERF_OUTPUT(meta_events);
|
||||
|
||||
static __always_inline int should_emit_sample(__u32 packet_mark, __u32 every) {
|
||||
if (every == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (every == 1) {
|
||||
return 1;
|
||||
}
|
||||
return (packet_mark % every) == 0;
|
||||
}
|
||||
|
||||
static __always_inline __u32 ensure_packet_mark(struct __sk_buff *skb) {
|
||||
__u32 next = skb->mark;
|
||||
|
||||
if (next) {
|
||||
return next;
|
||||
}
|
||||
|
||||
next = (__u32)(bpf_ktime_get_ns() & 0x0FFFFFFF);
|
||||
if (!next) {
|
||||
next = 1;
|
||||
}
|
||||
|
||||
skb->mark = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
static __always_inline int parse_l3_l4_direct(struct event_t *event, void *data, void *data_end) {
|
||||
struct ethhdr *eth = data;
|
||||
__be16 eth_proto;
|
||||
void *l3;
|
||||
|
||||
if ((void *)(eth + 1) > data_end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
__builtin_memcpy(event->src_mac, eth->h_source, ETH_ALEN);
|
||||
__builtin_memcpy(event->dst_mac, eth->h_dest, ETH_ALEN);
|
||||
eth_proto = eth->h_proto;
|
||||
l3 = eth + 1;
|
||||
|
||||
if (eth_proto == htons(ETH_P_8021Q) || eth_proto == htons(ETH_P_8021AD)) {
|
||||
struct vlan_hdr_t *vlan = l3;
|
||||
if ((void *)(vlan + 1) > data_end) {
|
||||
return 0;
|
||||
}
|
||||
event->vlan_id = ntohs(vlan->h_vlan_TCI) & 0x0fff;
|
||||
eth_proto = vlan->h_vlan_encapsulated_proto;
|
||||
l3 = vlan + 1;
|
||||
}
|
||||
|
||||
event->eth_type_raw = ntohs(eth_proto);
|
||||
|
||||
if (eth_proto == htons(ETH_P_ARP)) {
|
||||
struct arphdr *arph = l3;
|
||||
struct arp_eth_ipv4_t *body = (void *)(arph + 1);
|
||||
if ((void *)(body + 1) > data_end) {
|
||||
return 1;
|
||||
}
|
||||
if (arph->ar_hrd == htons(ARPHRD_ETHER) && arph->ar_pro == htons(ETH_P_IP) &&
|
||||
arph->ar_hln == ETH_ALEN && arph->ar_pln == 4) {
|
||||
__builtin_memcpy(event->src_ip, body->spa, 4);
|
||||
__builtin_memcpy(event->dst_ip, body->tpa, 4);
|
||||
event->ip_version = 4;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (eth_proto == htons(ETH_P_IP)) {
|
||||
struct iphdr *iph = l3;
|
||||
if ((void *)(iph + 1) > data_end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
event->ip_version = 4;
|
||||
event->protocol_raw = iph->protocol;
|
||||
__builtin_memcpy(event->src_ip, &iph->saddr, 4);
|
||||
__builtin_memcpy(event->dst_ip, &iph->daddr, 4);
|
||||
|
||||
if (iph->protocol == IPPROTO_TCP) {
|
||||
struct tcphdr *tcph = (void *)iph + (iph->ihl * 4);
|
||||
if ((void *)(tcph + 1) > data_end) {
|
||||
return 1;
|
||||
}
|
||||
event->src_port = ntohs(tcph->source);
|
||||
event->dst_port = ntohs(tcph->dest);
|
||||
} else if (iph->protocol == IPPROTO_UDP) {
|
||||
struct udphdr *udph = (void *)iph + (iph->ihl * 4);
|
||||
if ((void *)(udph + 1) > data_end) {
|
||||
return 1;
|
||||
}
|
||||
event->src_port = ntohs(udph->source);
|
||||
event->dst_port = ntohs(udph->dest);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (eth_proto == htons(ETH_P_IPV6)) {
|
||||
struct ipv6hdr *ip6h = l3;
|
||||
if ((void *)(ip6h + 1) > data_end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
event->ip_version = 6;
|
||||
event->protocol_raw = ip6h->nexthdr;
|
||||
__builtin_memcpy(event->src_ip, &ip6h->saddr, 16);
|
||||
__builtin_memcpy(event->dst_ip, &ip6h->daddr, 16);
|
||||
|
||||
if (ip6h->nexthdr == IPPROTO_TCP) {
|
||||
struct tcphdr *tcph = (void *)(ip6h + 1);
|
||||
if ((void *)(tcph + 1) > data_end) {
|
||||
return 1;
|
||||
}
|
||||
event->src_port = ntohs(tcph->source);
|
||||
event->dst_port = ntohs(tcph->dest);
|
||||
} else if (ip6h->nexthdr == IPPROTO_UDP) {
|
||||
struct udphdr *udph = (void *)(ip6h + 1);
|
||||
if ((void *)(udph + 1) > data_end) {
|
||||
return 1;
|
||||
}
|
||||
event->src_port = ntohs(udph->source);
|
||||
event->dst_port = ntohs(udph->dest);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static __always_inline int parse_skb_linear(struct event_t *event, struct sk_buff *skb) {
|
||||
unsigned char *head = NULL;
|
||||
__u16 mac_header = 0;
|
||||
__u16 network_header = 0;
|
||||
__u16 transport_header = 0;
|
||||
|
||||
if (!skb) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
bpf_probe_read_kernel(&head, sizeof(head), &skb->head);
|
||||
bpf_probe_read_kernel(&mac_header, sizeof(mac_header), &skb->mac_header);
|
||||
bpf_probe_read_kernel(&network_header, sizeof(network_header), &skb->network_header);
|
||||
bpf_probe_read_kernel(&transport_header, sizeof(transport_header), &skb->transport_header);
|
||||
bpf_probe_read_kernel(&event->length, sizeof(event->length), &skb->len);
|
||||
bpf_probe_read_kernel(&event->skb_mark, sizeof(event->skb_mark), &skb->mark);
|
||||
|
||||
if (!head) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
struct ethhdr eth = {};
|
||||
unsigned char *eth_ptr = head + mac_header;
|
||||
bpf_probe_read_kernel(ð, sizeof(eth), eth_ptr);
|
||||
__builtin_memcpy(event->src_mac, eth.h_source, ETH_ALEN);
|
||||
__builtin_memcpy(event->dst_mac, eth.h_dest, ETH_ALEN);
|
||||
|
||||
__be16 eth_proto = eth.h_proto;
|
||||
unsigned char *l3_ptr = head + network_header;
|
||||
if (eth_proto == htons(ETH_P_8021Q) || eth_proto == htons(ETH_P_8021AD)) {
|
||||
struct vlan_hdr_t vlan = {};
|
||||
bpf_probe_read_kernel(&vlan, sizeof(vlan), eth_ptr + sizeof(struct ethhdr));
|
||||
event->vlan_id = ntohs(vlan.h_vlan_TCI) & 0x0fff;
|
||||
eth_proto = vlan.h_vlan_encapsulated_proto;
|
||||
}
|
||||
event->eth_type_raw = ntohs(eth_proto);
|
||||
|
||||
if (eth_proto == htons(ETH_P_ARP)) {
|
||||
struct arphdr arph = {};
|
||||
struct arp_eth_ipv4_t arp_body = {};
|
||||
bpf_probe_read_kernel(&arph, sizeof(arph), l3_ptr);
|
||||
if (arph.ar_hrd == htons(ARPHRD_ETHER) && arph.ar_pro == htons(ETH_P_IP) &&
|
||||
arph.ar_hln == ETH_ALEN && arph.ar_pln == 4) {
|
||||
bpf_probe_read_kernel(&arp_body, sizeof(arp_body), l3_ptr + sizeof(struct arphdr));
|
||||
__builtin_memcpy(event->src_ip, arp_body.spa, 4);
|
||||
__builtin_memcpy(event->dst_ip, arp_body.tpa, 4);
|
||||
event->ip_version = 4;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (eth_proto == htons(ETH_P_IP)) {
|
||||
struct iphdr iph = {};
|
||||
bpf_probe_read_kernel(&iph, sizeof(iph), l3_ptr);
|
||||
event->ip_version = 4;
|
||||
event->protocol_raw = iph.protocol;
|
||||
bpf_probe_read_kernel(event->src_ip, 4, &iph.saddr);
|
||||
bpf_probe_read_kernel(event->dst_ip, 4, &iph.daddr);
|
||||
|
||||
if (iph.protocol == IPPROTO_TCP) {
|
||||
struct tcphdr tcph = {};
|
||||
bpf_probe_read_kernel(&tcph, sizeof(tcph), head + transport_header);
|
||||
event->src_port = ntohs(tcph.source);
|
||||
event->dst_port = ntohs(tcph.dest);
|
||||
} else if (iph.protocol == IPPROTO_UDP) {
|
||||
struct udphdr udph = {};
|
||||
bpf_probe_read_kernel(&udph, sizeof(udph), head + transport_header);
|
||||
event->src_port = ntohs(udph.source);
|
||||
event->dst_port = ntohs(udph.dest);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (eth_proto == htons(ETH_P_IPV6)) {
|
||||
struct ipv6hdr ip6h = {};
|
||||
bpf_probe_read_kernel(&ip6h, sizeof(ip6h), l3_ptr);
|
||||
event->ip_version = 6;
|
||||
event->protocol_raw = ip6h.nexthdr;
|
||||
__builtin_memcpy(event->src_ip, &ip6h.saddr, 16);
|
||||
__builtin_memcpy(event->dst_ip, &ip6h.daddr, 16);
|
||||
|
||||
if (ip6h.nexthdr == IPPROTO_TCP) {
|
||||
struct tcphdr tcph = {};
|
||||
bpf_probe_read_kernel(&tcph, sizeof(tcph), head + transport_header);
|
||||
event->src_port = ntohs(tcph.source);
|
||||
event->dst_port = ntohs(tcph.dest);
|
||||
} else if (ip6h.nexthdr == IPPROTO_UDP) {
|
||||
struct udphdr udph = {};
|
||||
bpf_probe_read_kernel(&udph, sizeof(udph), head + transport_header);
|
||||
event->src_port = ntohs(udph.source);
|
||||
event->dst_port = ntohs(udph.dest);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
int handle_ingress(struct __sk_buff *skb) {
|
||||
struct event_t event = {};
|
||||
void *data = (void *)(long)skb->data;
|
||||
void *data_end = (void *)(long)skb->data_end;
|
||||
|
||||
event.ts_ns = bpf_ktime_get_ns();
|
||||
event.event_type = EVENT_INGRESS;
|
||||
event.ifindex = skb->ifindex;
|
||||
event.length = skb->len;
|
||||
event.skb_mark = ensure_packet_mark(skb);
|
||||
|
||||
if (!event.skb_mark) {
|
||||
return TC_ACT_OK;
|
||||
}
|
||||
if (!parse_l3_l4_direct(&event, data, data_end)) {
|
||||
return TC_ACT_OK;
|
||||
}
|
||||
|
||||
if (should_emit_sample(event.skb_mark, RAW_SAMPLE_EVERY)) {
|
||||
ingress_events.perf_submit_skb(skb, skb->len, &event, sizeof(event));
|
||||
} else if (should_emit_sample(event.skb_mark, META_SAMPLE_EVERY)) {
|
||||
meta_events.perf_submit(skb, &event, sizeof(event));
|
||||
}
|
||||
return TC_ACT_OK;
|
||||
}
|
||||
|
||||
int handle_egress(struct __sk_buff *skb) {
|
||||
struct event_t event = {};
|
||||
void *data = (void *)(long)skb->data;
|
||||
void *data_end = (void *)(long)skb->data_end;
|
||||
|
||||
event.ts_ns = bpf_ktime_get_ns();
|
||||
event.event_type = EVENT_EGRESS;
|
||||
event.ifindex = skb->ifindex;
|
||||
event.length = skb->len;
|
||||
event.skb_mark = skb->mark;
|
||||
|
||||
if (!event.skb_mark) {
|
||||
return TC_ACT_OK;
|
||||
}
|
||||
if (!parse_l3_l4_direct(&event, data, data_end)) {
|
||||
return TC_ACT_OK;
|
||||
}
|
||||
|
||||
if (should_emit_sample(event.skb_mark, META_SAMPLE_EVERY)) {
|
||||
meta_events.perf_submit(skb, &event, sizeof(event));
|
||||
}
|
||||
return TC_ACT_OK;
|
||||
}
|
||||
|
||||
TRACEPOINT_PROBE(skb, kfree_skb) {
|
||||
struct sk_buff *skb = (struct sk_buff *)args->skbaddr;
|
||||
struct event_t event = {};
|
||||
struct net_device *dev = NULL;
|
||||
|
||||
event.ts_ns = bpf_ktime_get_ns();
|
||||
event.event_type = EVENT_DROP;
|
||||
event.reason = args->reason;
|
||||
|
||||
if (!skb) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
bpf_probe_read_kernel(&dev, sizeof(dev), &skb->dev);
|
||||
if (!dev) {
|
||||
return 0;
|
||||
}
|
||||
bpf_probe_read_kernel(&event.ifindex, sizeof(event.ifindex), &dev->ifindex);
|
||||
if (!parse_skb_linear(&event, skb)) {
|
||||
return 0;
|
||||
}
|
||||
if (!event.skb_mark) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (should_emit_sample(event.skb_mark, META_SAMPLE_EVERY)) {
|
||||
meta_events.perf_submit(args, &event, sizeof(event));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Event(ct.Structure):
|
||||
_fields_ = [
|
||||
("ts_ns", ct.c_ulonglong),
|
||||
("skb_mark", ct.c_uint),
|
||||
("length", ct.c_uint),
|
||||
("reason", ct.c_uint),
|
||||
("ifindex", ct.c_uint),
|
||||
("eth_type_raw", ct.c_ushort),
|
||||
("vlan_id", ct.c_ushort),
|
||||
("src_port", ct.c_ushort),
|
||||
("dst_port", ct.c_ushort),
|
||||
("protocol_raw", ct.c_uint),
|
||||
("event_type", ct.c_ubyte),
|
||||
("ip_version", ct.c_ubyte),
|
||||
("reserved", ct.c_ubyte * 2),
|
||||
("src_mac", ct.c_ubyte * 6),
|
||||
("dst_mac", ct.c_ubyte * 6),
|
||||
("src_ip", ct.c_ubyte * 16),
|
||||
("dst_ip", ct.c_ubyte * 16),
|
||||
]
|
||||
|
||||
|
||||
TARGET_INTERFACES: set[str] = set()
|
||||
IPR: IPRoute | None = None
|
||||
OMIT_RAW_PAYLOAD = False
|
||||
|
||||
|
||||
def _run_checked(cmd: list[str]) -> None:
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _ifname_from_index(ifindex: int) -> str | None:
|
||||
if ifindex <= 0:
|
||||
return None
|
||||
try:
|
||||
return socket.if_indextoname(ifindex)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _mac_to_str(value: Iterable[int]) -> str:
|
||||
return ":".join(f"{byte:02x}" for byte in value)
|
||||
|
||||
|
||||
def _ip_to_str(ip_version: int, raw: Iterable[int]) -> str | None:
|
||||
data = bytes(raw)
|
||||
if ip_version == 4:
|
||||
try:
|
||||
return str(ipaddress.IPv4Address(data[:4]))
|
||||
except ipaddress.AddressValueError:
|
||||
return None
|
||||
if ip_version == 6:
|
||||
try:
|
||||
return str(ipaddress.IPv6Address(data[:16]))
|
||||
except ipaddress.AddressValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _build_packet_uid(payload: dict[str, object]) -> str:
|
||||
normalized = []
|
||||
for field in IDENTITY_FIELDS:
|
||||
value = payload.get(field)
|
||||
normalized.append("" if value is None else str(value))
|
||||
return hashlib.sha1("|".join(normalized).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _event_name(value: int) -> str:
|
||||
return {EVENT_INGRESS: "ingress", EVENT_EGRESS: "egress", EVENT_DROP: "drop"}.get(value, "unknown")
|
||||
|
||||
|
||||
def _reason_name(reason: int) -> str:
|
||||
return f"skb_drop_reason_{reason}"
|
||||
|
||||
|
||||
def _build_payload(event: Event) -> dict[str, object] | None:
|
||||
iface = _ifname_from_index(int(event.ifindex))
|
||||
if iface not in TARGET_INTERFACES:
|
||||
return None
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"event_type": _event_name(int(event.event_type)),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"kernel_ts_ns": int(event.ts_ns),
|
||||
"iface": iface,
|
||||
"skb_mark": int(event.skb_mark) or None,
|
||||
"length": int(event.length),
|
||||
"src_mac": _mac_to_str(event.src_mac),
|
||||
"dst_mac": _mac_to_str(event.dst_mac),
|
||||
"eth_type_raw": int(event.eth_type_raw) or None,
|
||||
"vlan_id": int(event.vlan_id) or None,
|
||||
"src_ip": _ip_to_str(int(event.ip_version), event.src_ip),
|
||||
"dst_ip": _ip_to_str(int(event.ip_version), event.dst_ip),
|
||||
"protocol_raw": int(event.protocol_raw) or None,
|
||||
"src_port": int(event.src_port) or None,
|
||||
"dst_port": int(event.dst_port) or None,
|
||||
"reason": _reason_name(int(event.reason)) if int(event.event_type) == EVENT_DROP else None,
|
||||
"reason_code": int(event.reason) if int(event.event_type) == EVENT_DROP else None,
|
||||
}
|
||||
|
||||
packet_id = packet_id_from_mark(payload.get("skb_mark"))
|
||||
if packet_id:
|
||||
payload["packet_id"] = packet_id
|
||||
payload["correlation_key"] = f"pid:{packet_id}"
|
||||
payload["correlation_source"] = "kernel_mark"
|
||||
verdict_hint = verdict_from_mark(payload.get("skb_mark"))
|
||||
if verdict_hint:
|
||||
payload["verdict_hint"] = verdict_hint
|
||||
else:
|
||||
payload["packet_uid"] = _build_packet_uid(payload)
|
||||
payload["correlation_key"] = f"uid:{payload['packet_uid']}"
|
||||
payload["correlation_source"] = "legacy_hash"
|
||||
return payload
|
||||
|
||||
|
||||
def _emit_ingress_event(cpu: int, data: int, size: int) -> None:
|
||||
del cpu
|
||||
event = ct.cast(data, ct.POINTER(Event)).contents
|
||||
payload = _build_payload(event)
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
raw_size = size - ct.sizeof(Event)
|
||||
if raw_size > 0 and not OMIT_RAW_PAYLOAD:
|
||||
raw = ct.string_at(data + ct.sizeof(Event), min(raw_size, int(event.length)))
|
||||
payload["raw_b64"] = base64.b64encode(raw).decode("ascii")
|
||||
|
||||
print(json.dumps(payload, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def _emit_meta_event(cpu: int, data: int, size: int) -> None:
|
||||
del cpu, size
|
||||
event = ct.cast(data, ct.POINTER(Event)).contents
|
||||
payload = _build_payload(event)
|
||||
if payload is None:
|
||||
return
|
||||
print(json.dumps(payload, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def _build_bpf_source(raw_sample_every: int, meta_sample_every: int) -> str:
|
||||
return (
|
||||
BPF_SOURCE.replace("__RAW_SAMPLE_EVERY__", str(max(0, raw_sample_every)))
|
||||
.replace("__META_SAMPLE_EVERY__", str(max(0, meta_sample_every)))
|
||||
)
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="tc/eBPF bridge telemetry collector")
|
||||
parser.add_argument("--ifaces", required=True, help="Comma-separated list of interfaces to instrument")
|
||||
parser.add_argument("--build-dir", required=True, help="Directory for compiled tc BPF objects")
|
||||
parser.add_argument(
|
||||
"--raw-sample-every",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Emit full raw ingress packets every Nth marked packet. Use 0 to disable raw packet export.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--meta-sample-every",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Emit metadata events every Nth marked packet. Use 0 to disable metadata-only events.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ingress-pages",
|
||||
type=int,
|
||||
default=256,
|
||||
help="Perf-buffer page count for raw ingress packet events.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--meta-pages",
|
||||
type=int,
|
||||
default=128,
|
||||
help="Perf-buffer page count for metadata events.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--omit-raw-payload",
|
||||
action="store_true",
|
||||
help="Drain raw ingress events but do not base64-encode or print raw packet bytes.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _sigterm(_signum: int, _frame: object) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
def _json_safe(value: object) -> object:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", "replace")
|
||||
return value
|
||||
|
||||
|
||||
def _ensure_clean_clsact(iface: str) -> None:
|
||||
subprocess.run(["tc", "qdisc", "del", "dev", iface, "clsact"], check=False, capture_output=True, text=True)
|
||||
_run_checked(["tc", "qdisc", "add", "dev", iface, "clsact"])
|
||||
|
||||
|
||||
def _attach_tc_programs(bpf: BPF, ifaces: list[str], build_dir: str) -> tuple[str, str]:
|
||||
global IPR
|
||||
del build_dir
|
||||
ingress_fn = bpf.load_func("handle_ingress", BPF.SCHED_CLS)
|
||||
egress_fn = bpf.load_func("handle_egress", BPF.SCHED_CLS)
|
||||
|
||||
ipr = IPRoute()
|
||||
try:
|
||||
for iface in ifaces:
|
||||
matches = ipr.link_lookup(ifname=iface)
|
||||
if not matches:
|
||||
raise RuntimeError(f"Interface not found: {iface}")
|
||||
ifindex = matches[0]
|
||||
_ensure_clean_clsact(iface)
|
||||
ipr.tc(
|
||||
"add-filter",
|
||||
"bpf",
|
||||
ifindex,
|
||||
INGRESS_FILTER_HANDLE,
|
||||
fd=ingress_fn.fd,
|
||||
name=ingress_fn.name,
|
||||
parent=INGRESS_PARENT,
|
||||
classid=1,
|
||||
direct_action=True,
|
||||
)
|
||||
ipr.tc(
|
||||
"add-filter",
|
||||
"bpf",
|
||||
ifindex,
|
||||
EGRESS_FILTER_HANDLE,
|
||||
fd=egress_fn.fd,
|
||||
name=egress_fn.name,
|
||||
parent=EGRESS_PARENT,
|
||||
classid=1,
|
||||
direct_action=True,
|
||||
)
|
||||
except Exception:
|
||||
for iface in ifaces:
|
||||
subprocess.run(["tc", "qdisc", "del", "dev", iface, "clsact"], check=False, capture_output=True, text=True)
|
||||
if ipr is not None:
|
||||
ipr.close()
|
||||
IPR = None
|
||||
raise
|
||||
|
||||
return ingress_fn.name, egress_fn.name
|
||||
|
||||
|
||||
def _cleanup_tc(ifaces: Iterable[str]) -> None:
|
||||
for iface in ifaces:
|
||||
subprocess.run(["tc", "qdisc", "del", "dev", iface, "clsact"], check=False, capture_output=True, text=True)
|
||||
global IPR
|
||||
if IPR is not None:
|
||||
try:
|
||||
IPR.close()
|
||||
finally:
|
||||
IPR = None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
global OMIT_RAW_PAYLOAD
|
||||
OMIT_RAW_PAYLOAD = bool(args.omit_raw_payload)
|
||||
raw_sample_every = max(0, args.raw_sample_every)
|
||||
meta_sample_every = max(0, args.meta_sample_every)
|
||||
ingress_pages = max(1, args.ingress_pages)
|
||||
meta_pages = max(1, args.meta_pages)
|
||||
|
||||
global TARGET_INTERFACES
|
||||
TARGET_INTERFACES = {iface.strip() for iface in args.ifaces.split(",") if iface.strip()}
|
||||
if not TARGET_INTERFACES:
|
||||
print("No interfaces provided", file=sys.stderr, flush=True)
|
||||
return 1
|
||||
|
||||
signal.signal(signal.SIGTERM, _sigterm)
|
||||
signal.signal(signal.SIGINT, _sigterm)
|
||||
|
||||
bpf = BPF(text=_build_bpf_source(raw_sample_every, meta_sample_every))
|
||||
ingress_prog_name = ""
|
||||
egress_prog_name = ""
|
||||
try:
|
||||
ingress_prog_name, egress_prog_name = _attach_tc_programs(bpf, sorted(TARGET_INTERFACES), args.build_dir)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "collector_started",
|
||||
"ifaces": sorted(TARGET_INTERFACES),
|
||||
"ingress_program": _json_safe(ingress_prog_name),
|
||||
"egress_program": _json_safe(egress_prog_name),
|
||||
"build_dir": str(args.build_dir),
|
||||
"raw_sample_every": raw_sample_every,
|
||||
"meta_sample_every": meta_sample_every,
|
||||
"ingress_pages": ingress_pages,
|
||||
"meta_pages": meta_pages,
|
||||
"omit_raw_payload": OMIT_RAW_PAYLOAD,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
bpf["ingress_events"].open_perf_buffer(_emit_ingress_event, page_cnt=ingress_pages)
|
||||
bpf["meta_events"].open_perf_buffer(_emit_meta_event, page_cnt=meta_pages)
|
||||
while True:
|
||||
bpf.perf_buffer_poll()
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
_cleanup_tc(sorted(TARGET_INTERFACES))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,9 +1,15 @@
|
||||
from typing import List, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# ---- Logging ----------------------------------------------------------
|
||||
logger = logging.getLogger("af_packet_sniffer")
|
||||
logger = logging.getLogger("packet_capture")
|
||||
_ETHTOOL_BIN = "/usr/sbin/ethtool" if os.path.exists("/usr/sbin/ethtool") else "ethtool"
|
||||
_ETHERNET_PROFILE_CACHE_TTL_SECONDS = 3.0
|
||||
_ETHERNET_PROFILE_CACHE: Dict[str, tuple[float, Dict[str, Any]]] = {}
|
||||
|
||||
# -------------------------
|
||||
# Interface / bridge helpers
|
||||
@@ -30,6 +36,133 @@ def check_interface_up(iface: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _read_sysfs_text(path: str) -> Optional[str]:
|
||||
"""Read one sysfs file and return stripped text, or `None` if unavailable."""
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EINVAL, errno.EIO, errno.ENODEV}:
|
||||
logger.debug("Transient sysfs read failure for %s: %s", path, exc)
|
||||
return None
|
||||
logger.exception("Error reading sysfs path %s", path)
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("Error reading sysfs path %s", path)
|
||||
return None
|
||||
|
||||
|
||||
def read_interface_operstate(iface: str) -> Optional[str]:
|
||||
"""Return the kernel operstate string for an interface."""
|
||||
return _read_sysfs_text(f"/sys/class/net/{iface}/operstate")
|
||||
|
||||
|
||||
def read_interface_carrier(iface: str) -> Optional[bool]:
|
||||
"""Return the carrier state for an interface if available."""
|
||||
value = _read_sysfs_text(f"/sys/class/net/{iface}/carrier")
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(value, 10) == 1
|
||||
except ValueError:
|
||||
logger.warning("Unexpected carrier value for %s: %r", iface, value)
|
||||
return None
|
||||
|
||||
|
||||
def read_interface_admin_up(iface: str) -> Optional[bool]:
|
||||
"""Return whether the interface has the IFF_UP flag set."""
|
||||
value = _read_sysfs_text(f"/sys/class/net/{iface}/flags")
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return bool(int(value, 0) & 0x1)
|
||||
except ValueError:
|
||||
logger.warning("Unexpected flags value for %s: %r", iface, value)
|
||||
return None
|
||||
|
||||
|
||||
def read_interface_mtu(iface: str) -> Optional[int]:
|
||||
"""Return the interface MTU if available."""
|
||||
value = _read_sysfs_text(f"/sys/class/net/{iface}/mtu")
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(value, 10)
|
||||
except ValueError:
|
||||
logger.warning("Unexpected MTU value for %s: %r", iface, value)
|
||||
return None
|
||||
|
||||
|
||||
def read_interface_ethernet_profile(iface: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the current speed/duplex/autoneg profile when ethtool supports it."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_ETHTOOL_BIN, iface],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
return _get_cached_ethernet_profile(iface)
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
|
||||
speed_mbps: Optional[int] = None
|
||||
speed_value = values.get("Speed")
|
||||
if speed_value and speed_value.endswith("Mb/s"):
|
||||
try:
|
||||
speed_mbps = int(speed_value[:-4], 10)
|
||||
except ValueError:
|
||||
speed_mbps = None
|
||||
|
||||
duplex: Optional[str] = None
|
||||
duplex_value = values.get("Duplex")
|
||||
if duplex_value and duplex_value.lower() in {"full", "half"}:
|
||||
duplex = duplex_value.lower()
|
||||
|
||||
autoneg: Optional[bool] = None
|
||||
autoneg_value = values.get("Auto-negotiation")
|
||||
if autoneg_value:
|
||||
lowered = autoneg_value.lower()
|
||||
if lowered in {"on", "off"}:
|
||||
autoneg = lowered == "on"
|
||||
|
||||
if speed_mbps is None and duplex is None and autoneg is None:
|
||||
return _get_cached_ethernet_profile(iface)
|
||||
|
||||
profile = {
|
||||
"speed_mbps": speed_mbps,
|
||||
"duplex": duplex,
|
||||
"autoneg": autoneg,
|
||||
}
|
||||
_ETHERNET_PROFILE_CACHE[iface] = (time.time(), profile)
|
||||
return profile
|
||||
|
||||
|
||||
def _get_cached_ethernet_profile(iface: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a recent ethtool snapshot to smooth short renegotiation gaps."""
|
||||
cached = _ETHERNET_PROFILE_CACHE.get(iface)
|
||||
if cached is None:
|
||||
return None
|
||||
|
||||
ts, profile = cached
|
||||
if time.time() - ts > _ETHERNET_PROFILE_CACHE_TTL_SECONDS:
|
||||
_ETHERNET_PROFILE_CACHE.pop(iface, None)
|
||||
return None
|
||||
|
||||
return dict(profile)
|
||||
|
||||
|
||||
def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]:
|
||||
"""
|
||||
Read bridge member interfaces from sysfs. Internal helper that always reads.
|
||||
|
||||
@@ -1,140 +1,103 @@
|
||||
# src/utilities/packet_broadcaster.py
|
||||
"""In-process packet broadcaster for websocket subscribers."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("packet_broadcaster")
|
||||
|
||||
_SHUTDOWN_SENTINEL: Dict[str, Any] = {"type": "__broadcaster_shutdown__"}
|
||||
|
||||
|
||||
class PacketBroadcaster:
|
||||
"""
|
||||
Simple in-process broadcaster:
|
||||
- Maintains a set of subscriber asyncio.Queues (one per websocket connection).
|
||||
- publish(msg) is run on the broadcaster's event loop.
|
||||
- sync_publish(msg) is thread-safe and can be called from other threads / loops.
|
||||
|
||||
Note: create this on the FastAPI event loop (e.g. in startup) so that its lock and
|
||||
operations run on that same loop.
|
||||
"""
|
||||
"""Manage subscriber queues and publish packet events."""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop, queue_maxsize: int = 1024):
|
||||
self._loop = loop
|
||||
self._queue_maxsize = queue_maxsize
|
||||
|
||||
# create lock and subscribers on the target loop to avoid cross-loop asyncio primitives
|
||||
self._subscribers: List[asyncio.Queue] = []
|
||||
# create lock bound to the same loop by scheduling its construction on that loop
|
||||
self._lock: Optional[asyncio.Lock] = None
|
||||
self._closed = False
|
||||
|
||||
try:
|
||||
# ensure lock is created on the given loop
|
||||
def _make_lock():
|
||||
def _make_lock() -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
loop.call_soon_threadsafe(_make_lock)
|
||||
except Exception:
|
||||
# fallback — create in current loop if call_soon_threadsafe fails
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
self._closed = False
|
||||
|
||||
async def subscribe(self) -> asyncio.Queue:
|
||||
"""
|
||||
Create a subscriber queue and add it to the list.
|
||||
Caller is expected to await on the returned queue to receive messages.
|
||||
"""
|
||||
"""Create and register a queue for one subscriber."""
|
||||
if self._closed:
|
||||
raise RuntimeError("PacketBroadcaster is closed")
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=self._queue_maxsize)
|
||||
# wait until lock exists
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=self._queue_maxsize)
|
||||
while self._lock is None:
|
||||
await asyncio.sleep(0) # yield to event loop briefly
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async with self._lock:
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
self._subscribers.append(queue)
|
||||
|
||||
async def unsubscribe(self, q: asyncio.Queue) -> None:
|
||||
"""
|
||||
Remove a subscriber queue if present.
|
||||
"""
|
||||
return queue
|
||||
|
||||
async def unsubscribe(self, queue: asyncio.Queue) -> None:
|
||||
"""Unregister a subscriber queue if it exists."""
|
||||
if self._lock is None:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
self._subscribers.remove(queue)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def publish(self, msg: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Publish msg to all subscribers (must be called on the broadcaster's loop).
|
||||
We use put_nowait to avoid blocking. If a subscriber queue is full we drop
|
||||
that subscriber's message to avoid backpressure.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
if self._lock is None:
|
||||
# not initialized yet; nothing to do
|
||||
"""Publish one message to all current subscribers."""
|
||||
if self._closed or self._lock is None:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
subscribers = list(self._subscribers)
|
||||
|
||||
for q in subs:
|
||||
for queue in subscribers:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
queue.put_nowait(msg)
|
||||
except asyncio.QueueFull:
|
||||
# drop message for this subscriber
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error when publishing to subscriber: %s", exc)
|
||||
# attempt to remove broken subscriber
|
||||
logger.exception("Unexpected subscriber publish error: %s", exc)
|
||||
try:
|
||||
async with self._lock:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
if queue in self._subscribers:
|
||||
self._subscribers.remove(queue)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def sync_publish(self, msg: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Thread-safe publish method: schedule publish(msg) on the broadcaster's loop.
|
||||
Safe to call from other threads / event loops.
|
||||
|
||||
We schedule creation of the publish task on the broadcaster loop using
|
||||
call_soon_threadsafe so that publish() runs on the correct loop.
|
||||
"""
|
||||
"""Thread-safe wrapper that schedules `publish` on the broadcaster loop."""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
try:
|
||||
# schedule the coroutine to run on the broadcaster loop
|
||||
self._loop.call_soon_threadsafe(asyncio.create_task, self.publish(msg))
|
||||
except Exception as exc:
|
||||
# swallow errors but log for debugging
|
||||
logger.exception("sync_publish failed to schedule publish: %s", exc)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close the broadcaster: mark closed, clear subscribers, and drain queues.
|
||||
"""
|
||||
"""Close the broadcaster and clear queued messages."""
|
||||
self._closed = True
|
||||
if self._lock is None:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
subscribers = list(self._subscribers)
|
||||
self._subscribers.clear()
|
||||
|
||||
for q in subs:
|
||||
for queue in subscribers:
|
||||
try:
|
||||
# optionally notify subscribers of closure by putting None (client must handle)
|
||||
# q.put_nowait(None)
|
||||
while not q.empty():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
while not queue.empty():
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(_SHUTDOWN_SENTINEL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
44
backend/src/utilities/packet_identity.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Helpers for stable packet identity across capture and telemetry events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
IDENTITY_FIELDS = (
|
||||
"src_mac",
|
||||
"dst_mac",
|
||||
"eth_type_raw",
|
||||
"vlan_id",
|
||||
"src_ip",
|
||||
"dst_ip",
|
||||
"protocol_raw",
|
||||
"src_port",
|
||||
"dst_port",
|
||||
"length",
|
||||
"ip_id",
|
||||
"icmp_type",
|
||||
"icmp_code",
|
||||
"arp_op",
|
||||
"tcp_seq",
|
||||
"tcp_ack",
|
||||
"tcp_flags",
|
||||
)
|
||||
|
||||
|
||||
def build_packet_uid(fields: Dict[str, Any]) -> str:
|
||||
"""Build a deterministic packet identifier from selected L2-L4 fields."""
|
||||
normalized = []
|
||||
for field in IDENTITY_FIELDS:
|
||||
value = fields.get(field)
|
||||
if isinstance(value, bytes):
|
||||
value = value.hex()
|
||||
normalized.append("" if value is None else str(value))
|
||||
digest = hashlib.sha1("|".join(normalized).encode("utf-8")).hexdigest()
|
||||
return digest
|
||||
|
||||
|
||||
def minimal_identity_dict(fields: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return only the identity-relevant subset of packet fields."""
|
||||
return {field: fields.get(field) for field in IDENTITY_FIELDS}
|
||||
46
backend/src/utilities/packet_mark.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Helpers for the shared skb mark layout used for packet correlation and verdict hints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
PACKET_ID_MASK = 0x0FFFFFFF
|
||||
VERDICT_MASK = 0xF0000000
|
||||
VERDICT_FLAG_DROP = 0x10000000
|
||||
VERDICT_FLAG_REJECT = 0x20000000
|
||||
|
||||
|
||||
def normalize_skb_mark(value: object) -> Optional[int]:
|
||||
"""Return a positive integer skb mark or `None` when the value is empty."""
|
||||
if value in (None, "", 0, "0"):
|
||||
return None
|
||||
try:
|
||||
mark = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if mark < 0:
|
||||
mark &= 0xFFFFFFFF
|
||||
return mark or None
|
||||
|
||||
|
||||
def packet_id_from_mark(value: object) -> Optional[str]:
|
||||
"""Extract the packet correlation identifier from the shared skb mark layout."""
|
||||
mark = normalize_skb_mark(value)
|
||||
if mark is None:
|
||||
return None
|
||||
packet_id = mark & PACKET_ID_MASK
|
||||
return str(packet_id) if packet_id else None
|
||||
|
||||
|
||||
def verdict_from_mark(value: object) -> Optional[str]:
|
||||
"""Return a verdict hint encoded into the upper mark bits, if any."""
|
||||
mark = normalize_skb_mark(value)
|
||||
if mark is None:
|
||||
return None
|
||||
verdict_bits = mark & VERDICT_MASK
|
||||
if verdict_bits & VERDICT_FLAG_REJECT:
|
||||
return "reject"
|
||||
if verdict_bits & VERDICT_FLAG_DROP:
|
||||
return "drop"
|
||||
return None
|
||||
885
backend/src/utilities/packet_tracker.py
Normal file
@@ -0,0 +1,885 @@
|
||||
"""Aggregate packet observations and kernel telemetry into one packet record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import src.shared_objects as shared_objects
|
||||
from src.Models.etherType import EtherTypeEnum, ethertype_from_int
|
||||
from src.Models.ip_protocol import protocol_from_number
|
||||
from src.config import settings
|
||||
from src.utilities.packet_identity import build_packet_uid
|
||||
from src.utilities.packet_mark import packet_id_from_mark, verdict_from_mark
|
||||
|
||||
logger = logging.getLogger("packet_tracker")
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _observation_signature(observation: Dict[str, Any]) -> tuple[Any, ...]:
|
||||
return (
|
||||
observation.get("observation_type"),
|
||||
observation.get("source"),
|
||||
observation.get("iface"),
|
||||
observation.get("timestamp"),
|
||||
observation.get("event_type"),
|
||||
observation.get("capture_mode"),
|
||||
observation.get("capture_session_id"),
|
||||
observation.get("session_label"),
|
||||
observation.get("session_kind"),
|
||||
observation.get("reason"),
|
||||
observation.get("path_role"),
|
||||
observation.get("socket_pkttype"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_observation_timestamp(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
||||
if value in (None, ""):
|
||||
return datetime.max.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value))
|
||||
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return datetime.max.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _coerce_payload_timestamp(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
||||
if value not in (None, ""):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
pass
|
||||
return _utcnow()
|
||||
|
||||
|
||||
def _bridge_af_packet_observation_groups(payload: Dict[str, Any]) -> Dict[str, set[str]]:
|
||||
groups: Dict[str, set[str]] = {}
|
||||
observations = payload.get("capture_observations") or []
|
||||
if not isinstance(observations, list):
|
||||
return groups
|
||||
|
||||
for observation in observations:
|
||||
if not isinstance(observation, dict):
|
||||
continue
|
||||
if observation.get("observation_type") != "capture":
|
||||
continue
|
||||
if observation.get("source") != "af_packet":
|
||||
continue
|
||||
if observation.get("session_kind") != "bridge":
|
||||
continue
|
||||
if observation.get("capture_mode") != "af_packet":
|
||||
continue
|
||||
session_id = observation.get("capture_session_id")
|
||||
iface = observation.get("iface")
|
||||
if not session_id or not iface:
|
||||
continue
|
||||
groups.setdefault(str(session_id), set()).add(str(iface))
|
||||
return groups
|
||||
|
||||
|
||||
def _sorted_bridge_af_packet_observations(payload: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
||||
observations = payload.get("capture_observations") or []
|
||||
if not isinstance(observations, list):
|
||||
return grouped
|
||||
|
||||
for observation in observations:
|
||||
if not isinstance(observation, dict):
|
||||
continue
|
||||
if observation.get("observation_type") != "capture":
|
||||
continue
|
||||
if observation.get("source") != "af_packet":
|
||||
continue
|
||||
if observation.get("session_kind") != "bridge":
|
||||
continue
|
||||
if observation.get("capture_mode") != "af_packet":
|
||||
continue
|
||||
session_id = observation.get("capture_session_id")
|
||||
iface = observation.get("iface")
|
||||
if not session_id or not iface:
|
||||
continue
|
||||
grouped.setdefault(str(session_id), []).append(observation)
|
||||
|
||||
for session_id, items in grouped.items():
|
||||
items.sort(key=lambda item: (_parse_observation_timestamp(item.get("timestamp")), str(item.get("iface") or "")))
|
||||
grouped[session_id] = items
|
||||
return grouped
|
||||
|
||||
|
||||
def _bridge_af_packet_observation_path(
|
||||
observations: List[Dict[str, Any]],
|
||||
) -> tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
if not observations:
|
||||
return None, None
|
||||
|
||||
ingress_candidates = [item for item in observations if item.get("path_role") == "ingress"]
|
||||
egress_candidates = [item for item in observations if item.get("path_role") == "egress"]
|
||||
|
||||
ingress_observation = ingress_candidates[0] if ingress_candidates else observations[0]
|
||||
ingress_iface = ingress_observation.get("iface")
|
||||
ingress_ts = _parse_observation_timestamp(ingress_observation.get("timestamp"))
|
||||
|
||||
preferred_egress = next(
|
||||
(
|
||||
item
|
||||
for item in egress_candidates
|
||||
if item.get("iface") != ingress_iface
|
||||
),
|
||||
None,
|
||||
)
|
||||
if preferred_egress is not None:
|
||||
return ingress_observation, preferred_egress
|
||||
|
||||
fallback_egress = next(
|
||||
(
|
||||
item
|
||||
for item in observations
|
||||
if item.get("iface") != ingress_iface and _parse_observation_timestamp(item.get("timestamp")) >= ingress_ts
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fallback_egress is not None:
|
||||
return ingress_observation, fallback_egress
|
||||
|
||||
last_resort_egress = next(
|
||||
(
|
||||
item
|
||||
for item in observations
|
||||
if item.get("iface") != ingress_iface
|
||||
),
|
||||
None,
|
||||
)
|
||||
return ingress_observation, last_resort_egress
|
||||
|
||||
|
||||
class PacketTracker:
|
||||
"""Deduplicate packet observations and persist one upserted row per packet."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
finalize_delay_seconds: float = 0.25,
|
||||
retention_seconds: float = 10.0,
|
||||
min_flush_interval_seconds: float = 0.05,
|
||||
):
|
||||
self._finalize_delay_seconds = finalize_delay_seconds
|
||||
self._retention_seconds = retention_seconds
|
||||
self._min_flush_interval_seconds = min_flush_interval_seconds
|
||||
self._entries: Dict[str, Dict[str, Any]] = {}
|
||||
self._stats: Dict[str, int] = {
|
||||
"persisted_total": 0,
|
||||
"persisted_capture_only": 0,
|
||||
"persisted_telemetry_only": 0,
|
||||
"persisted_merged": 0,
|
||||
"persisted_with_raw": 0,
|
||||
"persisted_without_raw": 0,
|
||||
"persisted_kernel_mark": 0,
|
||||
"persisted_legacy_hash": 0,
|
||||
"persist_failed_total": 0,
|
||||
"persist_timeout_total": 0,
|
||||
"persist_failure_log_suppressed": 0,
|
||||
"persist_batch_total": 0,
|
||||
"persist_batch_failed_total": 0,
|
||||
"evicted_persisted_total": 0,
|
||||
"evicted_unpersisted_total": 0,
|
||||
"dropped_failed_persist_total": 0,
|
||||
"dropped_stale_dirty_total": 0,
|
||||
}
|
||||
self._last_persist_error_log_at = 0.0
|
||||
self._lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="packet-tracker")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=settings.packet_tracker_stop_join_timeout_seconds)
|
||||
|
||||
def discard_entries_for_ifaces(self, ifaces: List[str]) -> int:
|
||||
"""Drop in-memory entries that belong to interfaces no longer being sniffed."""
|
||||
targets = {iface for iface in ifaces if iface}
|
||||
if not targets:
|
||||
return 0
|
||||
|
||||
removed = 0
|
||||
with self._lock:
|
||||
for correlation_key in list(self._entries.keys()):
|
||||
payload = self._entries[correlation_key]["payload"]
|
||||
related_ifaces = {
|
||||
payload.get("ingress_if"),
|
||||
payload.get("egress_if"),
|
||||
payload.get("capture_iface"),
|
||||
}
|
||||
telemetry_metadata = payload.get("telemetry_metadata") or {}
|
||||
if isinstance(telemetry_metadata, dict):
|
||||
related_ifaces.add(telemetry_metadata.get("iface"))
|
||||
capture_observations = payload.get("capture_observations") or []
|
||||
if isinstance(capture_observations, list):
|
||||
for observation in capture_observations:
|
||||
if isinstance(observation, dict):
|
||||
related_ifaces.add(observation.get("iface"))
|
||||
|
||||
if related_ifaces & targets:
|
||||
self._entries.pop(correlation_key, None)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def observe_packet(self, pkt_info: Dict[str, Any]) -> str:
|
||||
"""Merge parsed packet information into a pending packet entry."""
|
||||
now_ts = time.time()
|
||||
correlation_key = self._ensure_correlation(pkt_info)
|
||||
pkt_info["raw_present"] = pkt_info.get("raw") is not None
|
||||
existing_sources = list(pkt_info.get("capture_sources") or [])
|
||||
primary_source = pkt_info.get("capture_source") or "af_packet"
|
||||
if primary_source not in existing_sources:
|
||||
existing_sources.insert(0, primary_source)
|
||||
pkt_info["capture_sources"] = existing_sources
|
||||
|
||||
with self._lock:
|
||||
entry = self._entries.get(correlation_key)
|
||||
if entry is None:
|
||||
entry = self._new_entry(correlation_key, now_ts)
|
||||
self._entries[correlation_key] = entry
|
||||
|
||||
self._merge_packet_info(entry, pkt_info, now_ts)
|
||||
self._maybe_promote_reject_from_reply(pkt_info, now_ts)
|
||||
self._maybe_mark_complete(entry)
|
||||
self._enforce_entry_limit_locked()
|
||||
return correlation_key
|
||||
|
||||
def observe_telemetry(self, event: Dict[str, Any]) -> Optional[str]:
|
||||
"""Merge ingress/egress/verdict telemetry into a pending packet entry."""
|
||||
correlation_key = self._ensure_correlation(event)
|
||||
if not correlation_key:
|
||||
logger.debug("Telemetry event missing packet identity: %s", event)
|
||||
return None
|
||||
|
||||
now_ts = time.time()
|
||||
with self._lock:
|
||||
entry = self._entries.get(correlation_key)
|
||||
if entry is None:
|
||||
entry = self._new_entry(correlation_key, now_ts)
|
||||
self._entries[correlation_key] = entry
|
||||
|
||||
payload = entry["payload"]
|
||||
payload["correlation_key"] = correlation_key
|
||||
payload["packet_id"] = event.get("packet_id") or payload.get("packet_id")
|
||||
payload["packet_uid"] = event.get("packet_uid") or payload.get("packet_uid")
|
||||
payload["correlation_source"] = event.get("correlation_source") or payload.get("correlation_source")
|
||||
payload["skb_mark"] = event.get("skb_mark") or payload.get("skb_mark")
|
||||
payload["telemetry_metadata"] = event
|
||||
payload["last_observed_at"] = now_ts
|
||||
event_timestamp = _coerce_payload_timestamp(event.get("timestamp"))
|
||||
current_timestamp = payload.get("timestamp")
|
||||
if current_timestamp in (None, "") or event_timestamp < _coerce_payload_timestamp(current_timestamp):
|
||||
payload["timestamp"] = event_timestamp
|
||||
self._add_capture_source(payload, "telemetry")
|
||||
self._add_capture_observation(
|
||||
payload,
|
||||
{
|
||||
"observation_type": "telemetry",
|
||||
"source": "telemetry",
|
||||
"iface": event.get("iface"),
|
||||
"timestamp": _utcnow().isoformat(),
|
||||
"event_type": event.get("event_type"),
|
||||
"capture_mode": "tc_ebpf",
|
||||
"reason": event.get("reason"),
|
||||
},
|
||||
)
|
||||
for key, value in event.items():
|
||||
if value is None or key in {"event_type", "reason", "reason_code", "iface", "packet_uid", "correlation_key"}:
|
||||
continue
|
||||
if payload.get(key) is None:
|
||||
payload[key] = value
|
||||
|
||||
if payload.get("eth_type") is None and payload.get("eth_type_raw") is not None:
|
||||
try:
|
||||
payload["eth_type"] = ethertype_from_int(int(payload["eth_type_raw"]))
|
||||
except Exception:
|
||||
payload["eth_type"] = EtherTypeEnum.UNKNOWN
|
||||
|
||||
if payload.get("protocol") is None and payload.get("protocol_raw") is not None:
|
||||
try:
|
||||
payload["protocol"] = protocol_from_number(int(payload["protocol_raw"]))
|
||||
except Exception:
|
||||
payload["protocol"] = int(payload["protocol_raw"])
|
||||
event_type = event.get("event_type")
|
||||
iface = event.get("iface")
|
||||
verdict_hint = event.get("verdict_hint")
|
||||
|
||||
if event_type == "ingress":
|
||||
payload["ingress_if"] = iface
|
||||
payload["ingress_seen_at"] = _utcnow()
|
||||
elif event_type == "egress":
|
||||
payload["egress_if"] = iface
|
||||
payload["egress_seen_at"] = _utcnow()
|
||||
payload["verdict"] = "accept"
|
||||
payload["verdict_reason"] = "egress-observed"
|
||||
payload["verdict_confidence"] = "high"
|
||||
payload["verdict_seen_at"] = _utcnow()
|
||||
elif event_type == "drop":
|
||||
payload["verdict"] = verdict_hint or "drop"
|
||||
payload["verdict_reason"] = event.get("reason") or ("mark-verdict" if verdict_hint else "kfree_skb")
|
||||
payload["verdict_confidence"] = "high"
|
||||
payload["verdict_seen_at"] = _utcnow()
|
||||
elif event_type == "reject" or verdict_hint == "reject":
|
||||
payload["verdict"] = "reject"
|
||||
payload["verdict_reason"] = event.get("reason") or "netfilter-reject"
|
||||
payload["verdict_confidence"] = event.get("verdict_confidence") or "medium"
|
||||
payload["verdict_seen_at"] = _utcnow()
|
||||
|
||||
entry["last_observed_at"] = now_ts
|
||||
if not entry["dirty"]:
|
||||
entry["first_dirty_at"] = now_ts
|
||||
entry["dirty"] = True
|
||||
self._maybe_mark_complete(entry)
|
||||
self._enforce_entry_limit_locked()
|
||||
return correlation_key
|
||||
|
||||
def _new_entry(self, correlation_key: str, now_ts: float) -> Dict[str, Any]:
|
||||
return {
|
||||
"correlation_key": correlation_key,
|
||||
"payload": {
|
||||
"timestamp": _utcnow(),
|
||||
"correlation_key": correlation_key,
|
||||
"correlation_source": None,
|
||||
"packet_id": None,
|
||||
"packet_uid": None,
|
||||
"capture_session_id": None,
|
||||
"skb_mark": None,
|
||||
"verdict": "pending",
|
||||
"verdict_reason": None,
|
||||
"verdict_confidence": None,
|
||||
"raw_present": False,
|
||||
"capture_sources": [],
|
||||
"capture_metadata": None,
|
||||
"telemetry_metadata": None,
|
||||
"capture_observations": [],
|
||||
},
|
||||
"persisted": False,
|
||||
"dirty": True,
|
||||
"finalized": False,
|
||||
"stats_recorded": False,
|
||||
"created_at": now_ts,
|
||||
"last_observed_at": now_ts,
|
||||
"last_persisted_at": 0.0,
|
||||
"last_persist_attempt_at": 0.0,
|
||||
"first_dirty_at": now_ts,
|
||||
"persist_failures": 0,
|
||||
}
|
||||
|
||||
def _enforce_entry_limit_locked(self) -> None:
|
||||
overflow = len(self._entries) - settings.packet_tracker_max_entries
|
||||
if overflow <= 0:
|
||||
return
|
||||
eviction_chunk = max(1, min(settings.packet_tracker_flush_batch_size, settings.packet_tracker_max_entries))
|
||||
evict_count = min(len(self._entries), max(overflow, eviction_chunk))
|
||||
|
||||
candidates = sorted(
|
||||
self._entries.items(),
|
||||
key=lambda item: (
|
||||
0 if item[1].get("persisted") else 1,
|
||||
0 if item[1].get("finalized") else 1,
|
||||
float(item[1].get("last_observed_at") or 0.0),
|
||||
),
|
||||
)
|
||||
for correlation_key, entry in candidates[:evict_count]:
|
||||
if entry.get("persisted"):
|
||||
self._stats["evicted_persisted_total"] += 1
|
||||
if entry.get("finalized") and not entry.get("stats_recorded"):
|
||||
self._record_stats(entry["payload"])
|
||||
entry["stats_recorded"] = True
|
||||
else:
|
||||
self._stats["evicted_unpersisted_total"] += 1
|
||||
self._entries.pop(correlation_key, None)
|
||||
|
||||
def _ensure_correlation(self, payload: Dict[str, Any]) -> Optional[str]:
|
||||
skb_mark = payload.get("skb_mark")
|
||||
if payload.get("packet_id") is None and skb_mark is not None:
|
||||
payload["packet_id"] = packet_id_from_mark(skb_mark)
|
||||
if payload.get("verdict_hint") is None and skb_mark is not None:
|
||||
payload["verdict_hint"] = verdict_from_mark(skb_mark)
|
||||
|
||||
packet_uid = payload.get("packet_uid")
|
||||
if packet_uid in (None, ""):
|
||||
try:
|
||||
packet_uid = build_packet_uid(payload)
|
||||
except Exception:
|
||||
packet_uid = None
|
||||
if packet_uid not in (None, ""):
|
||||
payload["packet_uid"] = packet_uid
|
||||
|
||||
packet_id = payload.get("packet_id")
|
||||
if packet_id not in (None, ""):
|
||||
packet_id = str(packet_id)
|
||||
payload["packet_id"] = packet_id
|
||||
payload["correlation_key"] = f"pid:{packet_id}"
|
||||
if not payload.get("correlation_source"):
|
||||
payload["correlation_source"] = "kernel_mark"
|
||||
return payload["correlation_key"]
|
||||
|
||||
if packet_uid in (None, ""):
|
||||
return None
|
||||
payload["correlation_key"] = f"uid:{packet_uid}"
|
||||
if not payload.get("correlation_source"):
|
||||
payload["correlation_source"] = "legacy_hash"
|
||||
return payload["correlation_key"]
|
||||
|
||||
def _add_capture_source(self, payload: Dict[str, Any], source: str) -> None:
|
||||
capture_sources = payload.setdefault("capture_sources", [])
|
||||
if source not in capture_sources:
|
||||
capture_sources.append(source)
|
||||
|
||||
def _add_capture_observation(self, payload: Dict[str, Any], observation: Optional[Dict[str, Any]]) -> bool:
|
||||
if not isinstance(observation, dict):
|
||||
return False
|
||||
|
||||
observations = payload.setdefault("capture_observations", [])
|
||||
if not isinstance(observations, list):
|
||||
observations = []
|
||||
payload["capture_observations"] = observations
|
||||
|
||||
normalized = {key: value for key, value in observation.items() if value is not None}
|
||||
signature = _observation_signature(normalized)
|
||||
for existing in observations:
|
||||
if isinstance(existing, dict) and _observation_signature(existing) == signature:
|
||||
return False
|
||||
|
||||
observations.append(normalized)
|
||||
observations.sort(key=lambda item: (_parse_observation_timestamp(item.get("timestamp")), str(item.get("iface") or "")))
|
||||
return True
|
||||
|
||||
def _merge_packet_info(self, entry: Dict[str, Any], pkt_info: Dict[str, Any], now_ts: float) -> None:
|
||||
payload = entry["payload"]
|
||||
changed = False
|
||||
self._add_capture_source(payload, pkt_info.get("capture_source") or "af_packet")
|
||||
if self._add_capture_observation(payload, pkt_info.get("capture_observation")):
|
||||
changed = True
|
||||
for key, value in pkt_info.items():
|
||||
if key in {"iface", "capture_source", "capture_observation"}:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if key == "timestamp":
|
||||
current_ts = payload.get("timestamp")
|
||||
if current_ts is None or value < current_ts:
|
||||
payload["timestamp"] = value
|
||||
changed = True
|
||||
continue
|
||||
if key == "raw" and payload.get("raw") is not None:
|
||||
continue
|
||||
if payload.get(key) == value:
|
||||
continue
|
||||
payload[key] = value
|
||||
changed = True
|
||||
|
||||
iface = pkt_info.get("iface")
|
||||
if iface and pkt_info.get("capture_iface") and not payload.get("capture_iface"):
|
||||
payload["capture_iface"] = pkt_info.get("capture_iface")
|
||||
changed = True
|
||||
elif iface and pkt_info.get("capture_metadata") and not payload.get("capture_iface"):
|
||||
payload["capture_iface"] = iface
|
||||
changed = True
|
||||
|
||||
capture_observation = pkt_info.get("capture_observation") or {}
|
||||
is_bridge_af_packet = (
|
||||
isinstance(capture_observation, dict)
|
||||
and capture_observation.get("session_kind") == "bridge"
|
||||
and capture_observation.get("capture_mode") == "af_packet"
|
||||
)
|
||||
|
||||
if iface and not pkt_info.get("capture_metadata") and not payload.get("ingress_if") and not is_bridge_af_packet:
|
||||
payload["ingress_if"] = iface
|
||||
payload["ingress_seen_at"] = _utcnow()
|
||||
changed = True
|
||||
|
||||
if self._maybe_backfill_bridge_af_packet_path(payload):
|
||||
changed = True
|
||||
|
||||
if self._maybe_infer_bridge_af_packet_accept(payload):
|
||||
changed = True
|
||||
|
||||
payload["last_observed_at"] = now_ts
|
||||
entry["last_observed_at"] = now_ts
|
||||
if changed and not entry["dirty"]:
|
||||
entry["first_dirty_at"] = now_ts
|
||||
entry["dirty"] = entry["dirty"] or changed
|
||||
|
||||
def _maybe_backfill_bridge_af_packet_path(self, payload: Dict[str, Any]) -> bool:
|
||||
if payload.get("telemetry_metadata") is not None:
|
||||
return False
|
||||
|
||||
observation_groups = _sorted_bridge_af_packet_observations(payload)
|
||||
if not observation_groups:
|
||||
return False
|
||||
|
||||
changed = False
|
||||
session_id, observations = min(
|
||||
observation_groups.items(),
|
||||
key=lambda item: (
|
||||
_parse_observation_timestamp(item[1][0].get("timestamp") if item[1] else None),
|
||||
str(item[0]),
|
||||
),
|
||||
)
|
||||
if not observations:
|
||||
return False
|
||||
|
||||
ingress_observation, egress_observation = _bridge_af_packet_observation_path(observations)
|
||||
if ingress_observation is None:
|
||||
return False
|
||||
ingress_iface = ingress_observation.get("iface")
|
||||
ingress_timestamp = ingress_observation.get("timestamp")
|
||||
|
||||
if ingress_iface and payload.get("ingress_if") != ingress_iface:
|
||||
payload["ingress_if"] = ingress_iface
|
||||
changed = True
|
||||
if ingress_timestamp:
|
||||
try:
|
||||
parsed_ingress_seen_at = datetime.fromisoformat(str(ingress_timestamp))
|
||||
if payload.get("ingress_seen_at") != parsed_ingress_seen_at:
|
||||
payload["ingress_seen_at"] = parsed_ingress_seen_at
|
||||
changed = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if egress_observation is None:
|
||||
return changed
|
||||
|
||||
egress_iface = egress_observation.get("iface")
|
||||
egress_timestamp = egress_observation.get("timestamp")
|
||||
if egress_iface and payload.get("egress_if") != egress_iface:
|
||||
payload["egress_if"] = egress_iface
|
||||
changed = True
|
||||
if egress_timestamp:
|
||||
try:
|
||||
parsed_egress_seen_at = datetime.fromisoformat(str(egress_timestamp))
|
||||
if payload.get("egress_seen_at") != parsed_egress_seen_at:
|
||||
payload["egress_seen_at"] = parsed_egress_seen_at
|
||||
changed = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return changed
|
||||
|
||||
def _maybe_infer_bridge_af_packet_accept(self, payload: Dict[str, Any]) -> bool:
|
||||
if payload.get("telemetry_metadata") is not None:
|
||||
return False
|
||||
|
||||
current_verdict = payload.get("verdict")
|
||||
if current_verdict not in {None, "", "pending", "unknown"}:
|
||||
return False
|
||||
|
||||
observation_groups = _bridge_af_packet_observation_groups(payload)
|
||||
if not any(len(ifaces) >= 2 for ifaces in observation_groups.values()):
|
||||
return False
|
||||
|
||||
changed = False
|
||||
if payload.get("verdict") != "accept":
|
||||
payload["verdict"] = "accept"
|
||||
changed = True
|
||||
if payload.get("verdict_reason") != "bridge-af_packet-forwarded-observed":
|
||||
payload["verdict_reason"] = "bridge-af_packet-forwarded-observed"
|
||||
changed = True
|
||||
if payload.get("verdict_confidence") != "medium":
|
||||
payload["verdict_confidence"] = "medium"
|
||||
changed = True
|
||||
if payload.get("verdict_seen_at") is None:
|
||||
payload["verdict_seen_at"] = _utcnow()
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def _maybe_promote_reject_from_reply(self, pkt_info: Dict[str, Any], now_ts: float) -> None:
|
||||
reject_reason = None
|
||||
|
||||
tcp_flags = pkt_info.get("tcp_flags")
|
||||
if pkt_info.get("protocol_raw") == 6 and tcp_flags is not None and int(tcp_flags) & 0x04:
|
||||
reject_reason = "tcp-rst-observed"
|
||||
match = self._find_recent_drop(
|
||||
src_ip=pkt_info.get("dst_ip"),
|
||||
dst_ip=pkt_info.get("src_ip"),
|
||||
protocol_raw=6,
|
||||
src_port=pkt_info.get("dst_port"),
|
||||
dst_port=pkt_info.get("src_port"),
|
||||
)
|
||||
elif pkt_info.get("protocol_raw") == 1 and pkt_info.get("icmp_type") == 3:
|
||||
reject_reason = "icmp-unreachable-observed"
|
||||
match = self._find_recent_drop(
|
||||
src_ip=pkt_info.get("icmp_embedded_src_ip"),
|
||||
dst_ip=pkt_info.get("icmp_embedded_dst_ip"),
|
||||
protocol_raw=pkt_info.get("icmp_embedded_protocol"),
|
||||
src_port=pkt_info.get("icmp_embedded_src_port"),
|
||||
dst_port=pkt_info.get("icmp_embedded_dst_port"),
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
if match is None:
|
||||
return
|
||||
|
||||
payload = match["payload"]
|
||||
if payload.get("verdict") != "drop":
|
||||
return
|
||||
|
||||
payload["verdict"] = "reject"
|
||||
payload["verdict_reason"] = reject_reason
|
||||
payload["verdict_confidence"] = "medium"
|
||||
payload["verdict_seen_at"] = _utcnow()
|
||||
match["last_observed_at"] = now_ts
|
||||
match["dirty"] = True
|
||||
match["finalized"] = True
|
||||
|
||||
def _find_recent_drop(
|
||||
self,
|
||||
src_ip: Any,
|
||||
dst_ip: Any,
|
||||
protocol_raw: Any,
|
||||
src_port: Any,
|
||||
dst_port: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not src_ip or not dst_ip or protocol_raw is None:
|
||||
return None
|
||||
|
||||
cutoff = time.time() - settings.packet_tracker_reject_correlation_window_seconds
|
||||
for entry in self._entries.values():
|
||||
payload = entry["payload"]
|
||||
if entry["last_observed_at"] < cutoff:
|
||||
continue
|
||||
if payload.get("verdict") != "drop":
|
||||
continue
|
||||
if payload.get("src_ip") != src_ip or payload.get("dst_ip") != dst_ip:
|
||||
continue
|
||||
if payload.get("protocol_raw") != protocol_raw:
|
||||
continue
|
||||
if payload.get("src_port") != src_port or payload.get("dst_port") != dst_port:
|
||||
continue
|
||||
return entry
|
||||
return None
|
||||
|
||||
def _maybe_mark_complete(self, entry: Dict[str, Any]) -> None:
|
||||
payload = entry["payload"]
|
||||
if payload.get("verdict") in {"accept", "drop", "reject"}:
|
||||
entry["finalized"] = True
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
time.sleep(0.05)
|
||||
due_entries: List[Dict[str, Any]] = []
|
||||
expired_keys: List[str] = []
|
||||
now_ts = time.time()
|
||||
|
||||
with self._lock:
|
||||
for correlation_key, entry in list(self._entries.items()):
|
||||
age = now_ts - entry["last_observed_at"]
|
||||
if not entry["finalized"] and age >= self._finalize_delay_seconds:
|
||||
entry["finalized"] = True
|
||||
if entry["payload"].get("verdict") == "pending":
|
||||
entry["payload"]["verdict"] = "unknown"
|
||||
entry["payload"]["verdict_reason"] = "timeout"
|
||||
entry["payload"]["verdict_confidence"] = "low"
|
||||
entry["payload"]["verdict_seen_at"] = _utcnow()
|
||||
if not entry["dirty"]:
|
||||
entry["first_dirty_at"] = now_ts
|
||||
entry["dirty"] = True
|
||||
|
||||
if self._should_drop_dirty_entry(entry, now_ts):
|
||||
if int(entry.get("persist_failures") or 0) >= settings.packet_tracker_max_persist_failures:
|
||||
self._stats["dropped_failed_persist_total"] += 1
|
||||
else:
|
||||
self._stats["dropped_stale_dirty_total"] += 1
|
||||
expired_keys.append(correlation_key)
|
||||
continue
|
||||
|
||||
should_flush = entry["dirty"] and (
|
||||
not entry["persisted"]
|
||||
or entry["finalized"]
|
||||
or (now_ts - entry["last_persisted_at"]) >= self._min_flush_interval_seconds
|
||||
)
|
||||
if should_flush and self._persist_backoff_elapsed(entry, now_ts):
|
||||
if len(due_entries) < settings.packet_tracker_flush_batch_size:
|
||||
due_entries.append(
|
||||
{
|
||||
"correlation_key": entry["correlation_key"],
|
||||
"payload": dict(entry["payload"]),
|
||||
}
|
||||
)
|
||||
entry["last_persist_attempt_at"] = now_ts
|
||||
elif entry["persisted"] and age >= self._retention_seconds:
|
||||
if entry["finalized"] and not entry["stats_recorded"]:
|
||||
self._record_stats(entry["payload"])
|
||||
entry["stats_recorded"] = True
|
||||
expired_keys.append(correlation_key)
|
||||
|
||||
for correlation_key in expired_keys:
|
||||
self._entries.pop(correlation_key, None)
|
||||
|
||||
self._persist_batch(due_entries)
|
||||
|
||||
def _persist_backoff_elapsed(self, entry: Dict[str, Any], now_ts: float) -> bool:
|
||||
failures = int(entry.get("persist_failures") or 0)
|
||||
if failures <= 0:
|
||||
return True
|
||||
|
||||
base = max(0.0, settings.packet_tracker_persist_retry_backoff_seconds)
|
||||
if base <= 0:
|
||||
return True
|
||||
|
||||
backoff = min(
|
||||
settings.packet_tracker_persist_retry_backoff_max_seconds,
|
||||
base * (2 ** min(failures - 1, 6)),
|
||||
)
|
||||
return now_ts - float(entry.get("last_persist_attempt_at") or 0.0) >= backoff
|
||||
|
||||
def _persist_batch(self, entries: List[Dict[str, Any]]) -> None:
|
||||
if not entries:
|
||||
return
|
||||
|
||||
payloads = [dict(entry["payload"]) for entry in entries]
|
||||
web_loop = getattr(shared_objects, "web_loop", None)
|
||||
web_db = getattr(shared_objects, "db", None)
|
||||
if web_loop is None or web_db is None:
|
||||
return
|
||||
|
||||
fut: concurrent.futures.Future[Any]
|
||||
try:
|
||||
if hasattr(web_db, "upsert_packets"):
|
||||
fut = asyncio.run_coroutine_threadsafe(web_db.upsert_packets(payloads), web_loop)
|
||||
else:
|
||||
fut = asyncio.run_coroutine_threadsafe(self._persist_payloads_one_by_one(web_db, payloads), web_loop)
|
||||
timeout = max(
|
||||
settings.packet_tracker_persist_timeout_seconds,
|
||||
settings.packet_tracker_batch_persist_timeout_seconds,
|
||||
)
|
||||
fut.result(timeout=timeout)
|
||||
with self._lock:
|
||||
self._stats["persist_batch_total"] += 1
|
||||
persisted_at = time.time()
|
||||
for entry in entries:
|
||||
current = self._entries.get(entry["correlation_key"])
|
||||
if current is not None:
|
||||
current["persisted"] = True
|
||||
current["dirty"] = False
|
||||
current["last_persisted_at"] = persisted_at
|
||||
current["persist_failures"] = 0
|
||||
except Exception as exc:
|
||||
try:
|
||||
fut.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_timeout = isinstance(exc, (TimeoutError, concurrent.futures.TimeoutError, asyncio.TimeoutError))
|
||||
with self._lock:
|
||||
self._stats["persist_batch_failed_total"] += 1
|
||||
self._stats["persist_failed_total"] += len(entries)
|
||||
if is_timeout:
|
||||
self._stats["persist_timeout_total"] += len(entries)
|
||||
for entry in entries:
|
||||
current = self._entries.get(entry["correlation_key"])
|
||||
if current is not None:
|
||||
if not current["dirty"]:
|
||||
current["first_dirty_at"] = time.time()
|
||||
current["dirty"] = True
|
||||
current["persist_failures"] = int(current.get("persist_failures") or 0) + 1
|
||||
|
||||
now_ts = time.time()
|
||||
if now_ts - self._last_persist_error_log_at >= settings.packet_tracker_error_log_interval_seconds:
|
||||
self._last_persist_error_log_at = now_ts
|
||||
logger.warning(
|
||||
"Packet persistence is overloaded; failed to persist batch of %s packets (%s). Further errors are rate-limited.",
|
||||
len(entries),
|
||||
type(exc).__name__,
|
||||
)
|
||||
else:
|
||||
with self._lock:
|
||||
self._stats["persist_failure_log_suppressed"] += 1
|
||||
|
||||
async def _persist_payloads_one_by_one(self, web_db: Any, payloads: List[Dict[str, Any]]) -> None:
|
||||
for payload in payloads:
|
||||
await web_db.upsert_packet(payload)
|
||||
|
||||
def _should_drop_dirty_entry(self, entry: Dict[str, Any], now_ts: float) -> bool:
|
||||
if not entry.get("dirty"):
|
||||
return False
|
||||
if entry.get("persisted"):
|
||||
return False
|
||||
if int(entry.get("persist_failures") or 0) >= settings.packet_tracker_max_persist_failures:
|
||||
return True
|
||||
max_dirty_age = settings.packet_tracker_max_dirty_age_seconds
|
||||
if max_dirty_age <= 0:
|
||||
return False
|
||||
return now_ts - float(entry.get("first_dirty_at") or entry.get("created_at") or now_ts) >= max_dirty_age
|
||||
|
||||
def _record_stats(self, payload: Dict[str, Any]) -> None:
|
||||
capture_sources = set(payload.get("capture_sources") or [])
|
||||
self._stats["persisted_total"] += 1
|
||||
if payload.get("raw_present"):
|
||||
self._stats["persisted_with_raw"] += 1
|
||||
else:
|
||||
self._stats["persisted_without_raw"] += 1
|
||||
if payload.get("correlation_source") == "kernel_mark":
|
||||
self._stats["persisted_kernel_mark"] = self._stats.get("persisted_kernel_mark", 0) + 1
|
||||
else:
|
||||
self._stats["persisted_legacy_hash"] = self._stats.get("persisted_legacy_hash", 0) + 1
|
||||
|
||||
if capture_sources and capture_sources != {"telemetry"} and "telemetry" not in capture_sources:
|
||||
self._stats["persisted_capture_only"] += 1
|
||||
elif capture_sources == {"telemetry"}:
|
||||
self._stats["persisted_telemetry_only"] += 1
|
||||
else:
|
||||
self._stats["persisted_merged"] += 1
|
||||
|
||||
def get_debug_snapshot(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
active_entries = list(self._entries.values())
|
||||
stats = dict(self._stats)
|
||||
|
||||
active_total = len(active_entries)
|
||||
active_with_raw = sum(1 for entry in active_entries if entry["payload"].get("raw_present"))
|
||||
active_without_raw = active_total - active_with_raw
|
||||
active_capture_only = 0
|
||||
active_telemetry_only = 0
|
||||
active_merged = 0
|
||||
active_kernel_mark = 0
|
||||
active_legacy_hash = 0
|
||||
for entry in active_entries:
|
||||
capture_sources = set(entry["payload"].get("capture_sources") or [])
|
||||
if capture_sources and capture_sources != {"telemetry"} and "telemetry" not in capture_sources:
|
||||
active_capture_only += 1
|
||||
elif capture_sources == {"telemetry"}:
|
||||
active_telemetry_only += 1
|
||||
else:
|
||||
active_merged += 1
|
||||
if entry["payload"].get("correlation_source") == "kernel_mark":
|
||||
active_kernel_mark += 1
|
||||
else:
|
||||
active_legacy_hash += 1
|
||||
|
||||
return {
|
||||
"active_total": active_total,
|
||||
"active_with_raw": active_with_raw,
|
||||
"active_without_raw": active_without_raw,
|
||||
"active_capture_only": active_capture_only,
|
||||
"active_telemetry_only": active_telemetry_only,
|
||||
"active_merged": active_merged,
|
||||
"active_kernel_mark": active_kernel_mark,
|
||||
"active_legacy_hash": active_legacy_hash,
|
||||
"cumulative": stats,
|
||||
}
|
||||
|
||||
|
||||
packet_tracker = PacketTracker(
|
||||
finalize_delay_seconds=settings.packet_tracker_finalize_delay_seconds,
|
||||
retention_seconds=settings.packet_tracker_retention_seconds,
|
||||
min_flush_interval_seconds=settings.packet_tracker_min_flush_interval_seconds,
|
||||
)
|
||||
1063
backend/src/utilities/tshark_manager.py
Normal file
39
documentation/backend/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Backend documentation
|
||||
|
||||
This directory documents the Python service in `backend/src`. It is written for
|
||||
developers and operators of the inline MITM test system. The source code remains
|
||||
the implementation authority; this documentation records the externally useful
|
||||
contracts, lifecycle, Linux integration, and data semantics that are easy to lose
|
||||
when reading individual modules.
|
||||
|
||||
## Reading order
|
||||
|
||||
1. [Architecture](architecture.md) explains the process, responsibilities, and
|
||||
lifecycle.
|
||||
2. [Sniffing modes](sniffing.md) gives the complete technical behavior and
|
||||
implications of AF_PACKET and TC/eBPF capture.
|
||||
3. [Capture pipeline](capture-pipeline.md) follows a packet from observation to
|
||||
persistence and realtime delivery.
|
||||
4. [HTTP and WebSocket API](api.md) lists every router mounted by the application.
|
||||
5. [Data and analysis](data-and-analysis.md) describes the packet record, database
|
||||
operations, and derived analysis views.
|
||||
6. [Host integration](host-integration.md) covers network, eBPF, nftables, tshark,
|
||||
and systemd side effects.
|
||||
7. [Configuration and deployment](configuration.md) records dependencies and all
|
||||
`BACKEND_*` settings.
|
||||
8. [Source reference](source-reference.md) documents every backend source module,
|
||||
including modules not mounted by the current application.
|
||||
|
||||
## Scope and conventions
|
||||
|
||||
All HTTP paths below include the FastAPI `root_path`, `/api`. The interactive
|
||||
schema is available at `/api/docs`, the alternative reference UI at `/api/redoc`,
|
||||
and the machine-readable contract at `/api/openapi.json`.
|
||||
|
||||
"Live" means a router is included by `src.main`. `nft_api.py` and
|
||||
`nftables_api.py` contain independent routers but are not included by the current
|
||||
entrypoint; they are documented as available-but-unmounted implementation paths.
|
||||
|
||||
Packet capture, firewall changes, bridge changes, and script deployment alter the
|
||||
host system. They must be used only in a controlled environment with explicit
|
||||
operator authorization.
|
||||
112
documentation/backend/api.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# HTTP and WebSocket API
|
||||
|
||||
The application is served below `/api`. FastAPI validates request models and
|
||||
publishes the complete JSON Schema at `/api/openapi.json`; use it for exact field
|
||||
types and the current response schema. This page documents semantics and all
|
||||
mounted operations.
|
||||
|
||||
## General endpoints
|
||||
|
||||
| Method/path | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /api/hello` | Simple application health response. |
|
||||
| `GET /api/versions` | Returns the Python runtime version. |
|
||||
|
||||
## Network: `/api/network`
|
||||
|
||||
| Method/path | Parameters/body | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `GET /interfaces` | none | Lists interfaces, addresses, flags, MTU, MAC, state and Ethernet profile. |
|
||||
| `GET /routes` | none | Lists kernel route entries and resolved output-interface names. |
|
||||
| `GET /links` | none | Lists raw link information. |
|
||||
| `GET /bridges` | none | Lists Linux bridges, STP state and current member details. |
|
||||
| `GET /full-state` | none | Combines interfaces, routes, links and bridges into one snapshot. |
|
||||
| `POST /interfaces/reset-defaults` | `{ interfaces: string[] }` | Resets each requested interface to MTU 1500 and attempts to restore an automatic Ethernet profile through `ethtool`. |
|
||||
| `POST /bridge/create` | `{ name, interfaces }` | Creates a Linux bridge and attaches listed interfaces. |
|
||||
| `POST /bridge/remove` | `{ name }` | Removes an existing bridge. |
|
||||
| `GET /bridge/link-state-watchers` | none | Returns all watcher states. |
|
||||
| `GET /bridge/{bridge_name}/link-state-watcher` | path name | Returns one bridge watcher state. |
|
||||
| `POST /bridge/{bridge_name}/link-state-watcher/enable` | optional recovery holdoff | Enables member failure/recovery propagation. |
|
||||
| `POST /bridge/{bridge_name}/link-state-watcher/disable` | path name | Stops and removes that watcher. |
|
||||
| `WS /ws/state` | none | Receives full network-state update payloads after network mutations. |
|
||||
|
||||
An interface object includes its kernel index, name, state, MAC, MTU, decoded flags,
|
||||
assigned IPv4/IPv6 addresses, and, where available, speed/duplex/autoneg data.
|
||||
|
||||
## Sniffer: `/api/sniffer`
|
||||
|
||||
| Method/path | Parameters/body | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `POST /start` | exactly one of `bridge` or `interface`; optional `bridge_capture_mode`, `benchmark_mode` | Creates a capture session. Bridge modes are `tc_ebpf` and `af_packet`. |
|
||||
| `POST /stop` | optional session ID or bridge/interface selector | Stops an identified session, target sessions, or all sessions according to the request. |
|
||||
| `GET /status` | none | Returns status keyed by captured interface: running/existing/up state, owner session, mode, and benchmark mode. |
|
||||
| `GET /debug` | none | Returns internal session, buffered-record, tshark, telemetry, and tracker state. Treat as diagnostic output, not a stable client contract. |
|
||||
|
||||
The start endpoint rejects requests containing both a bridge and an interface, or
|
||||
neither. A bridge defaults to `tc_ebpf`; an interface always captures using
|
||||
AF_PACKET.
|
||||
|
||||
## Packets: `/api/packets`
|
||||
|
||||
| Method/path | Parameters/body | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `GET /packets?limit=100` | `limit` 1–10,000 | Fetches most-recent normalized packet rows. |
|
||||
| `DELETE /packets?reset_id=true` | optional boolean | Clears packet history; can reset database identity state. |
|
||||
| `WS /ws/packets` | none | Receives packet updates from the in-process broadcaster. |
|
||||
|
||||
REST history is authoritative. WebSocket clients must expect connection loss and
|
||||
dropped messages for a slow subscriber, then refill missed state with `GET`.
|
||||
|
||||
## Analysis: `/api/analysis`
|
||||
|
||||
Every analysis endpoint accepts `since_minutes` when shown; its valid range is
|
||||
1 minute to 30 days. Results are derived from the stored packet history and do not
|
||||
claim ground truth about a physical topology or attack.
|
||||
|
||||
| Method/path | Main query controls | Result |
|
||||
| --- | --- | --- |
|
||||
| `GET /interface-hosts` | `since_minutes`, `limit_per_interface` | Likely hosts attached to each MITM-side interface. |
|
||||
| `GET /interface-host-protocols` | plus `limit_protocols_per_host` | Attachment inference with per-host protocol evidence. |
|
||||
| `GET /interface-protocol-paths` | `limit_paths` | Directional aggregated paths for a Sankey-style view. |
|
||||
| `GET /conversations` | `limit` | Aggregated directional endpoint conversations. |
|
||||
| `GET /conversation-flow-detail` | `flow_id` or directional endpoint/port fields; `protocol`, `limit_packets` | Ordered packets, subflows, and derived request/response events. |
|
||||
| `GET /host-intelligence` | `limit_hosts` | Host-centric peers, service and hostname hints. |
|
||||
| `GET /discovery` | `limit` | Discovery, naming and service-advertisement activity. |
|
||||
| `GET /anomalies` | `limit` | Heuristic scan, beacon, rare service, reset-heavy and drop-heavy candidates. |
|
||||
|
||||
`conversation-flow-detail` requires a `flow_id` or enough directional fields to
|
||||
identify a conversation. All analysis endpoints return 503 while the database is
|
||||
unavailable and 500 when their underlying query fails.
|
||||
|
||||
## Firewall: `/api/firewall`
|
||||
|
||||
| Method/path | Body/query | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `GET /rules` | none | Lists nftables ruleset in a predictable structured representation, enriched with textual rule data where possible. |
|
||||
| `DELETE /rules/{handle}` | optional family/table/chain defaults | Deletes the rule identified by its nft handle. |
|
||||
| `POST /raw` | `{ cmd: string }` | Executes an arbitrary textual nft command and returns stdout/stderr/return code. |
|
||||
|
||||
The raw endpoint is intentionally powerful and must not be exposed to untrusted
|
||||
clients. It changes the host firewall, not an application-local simulation.
|
||||
|
||||
## Scripts: `/api/scripts/scripts`
|
||||
|
||||
The doubled path is produced by the current combination of router and application
|
||||
prefixes. Scripts are Python NFQUEUE workers installed under `/srv/fw-scripts` and
|
||||
can have systemd units and isolated virtual environments.
|
||||
|
||||
| Method/path | Behaviour |
|
||||
| --- | --- |
|
||||
| `GET /` | Lists scripts and their unit mappings/status. |
|
||||
| `POST /` | Uploads a script multipart payload; accepts a script, optional requirements file and required name form field. |
|
||||
| `GET /{name}` | Downloads script source. |
|
||||
| `GET /{name}/requirements` | Downloads its requirements file. |
|
||||
| `PUT /{name}/requirements` | Replaces requirements and runs pip install in the script venv. |
|
||||
| `DELETE /{name}/requirements` | Deletes requirements and removes the venv. |
|
||||
| `POST /{name}/enable` | Creates/starts an NFQUEUE systemd service for a requested queue number. |
|
||||
| `POST /{name}/disable` | Stops/disables the service for a queue number. |
|
||||
| `DELETE /{name}` | Removes all, or one requested queue-number unit, then cleans script-related files as appropriate. |
|
||||
|
||||
Names allow letters, digits, `.`, `_`, and `-`; `.` and `..` are prohibited.
|
||||
Repository example scripts are protected from API modification. Enabling/uploading
|
||||
requirements has code-execution and host-service consequences.
|
||||
70
documentation/backend/architecture.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Backend architecture
|
||||
|
||||
## Process model
|
||||
|
||||
`src.main` constructs one FastAPI application with `root_path="/api"`. During
|
||||
startup it stores the running asyncio loop in `src.shared_objects`, creates an
|
||||
asyncpg `DatabasePool`, attaches a packet broadcaster to it, creates a second
|
||||
network-state broadcaster, and drains any capture records buffered before the DB
|
||||
became available. Shutdown stops capture, network resources, telemetry, tshark,
|
||||
and the packet tracker; then closes WebSocket broadcasters and the DB pool.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Frontend/client] --> API[FastAPI /api]
|
||||
API --> NET[Network and bridge API]
|
||||
API --> CAP[Sniffer API]
|
||||
API --> FW[Firewall API]
|
||||
API --> SCR[Script API]
|
||||
CAP --> NS[network_sniffer]
|
||||
NS --> PT[PacketTracker]
|
||||
EBPF[tc/eBPF telemetry process] --> PT
|
||||
NS <--> TS[tshark workers]
|
||||
PT --> DB[(PostgreSQL packets)]
|
||||
DB --> PB[PacketBroadcaster]
|
||||
PB --> WS1[Packet WebSocket]
|
||||
NET --> NB[Network broadcaster]
|
||||
NB --> WS2[Network WebSocket]
|
||||
API --> DB
|
||||
```
|
||||
|
||||
## Component boundaries
|
||||
|
||||
| Component | Responsibility | Persistent state | Important side effects |
|
||||
| --- | --- | --- | --- |
|
||||
| `main.py` | app construction and lifecycle wiring | shared object references | starts/stops resources |
|
||||
| `api/` | validates requests and presents HTTP/WebSocket contracts | none by default | may alter Linux networking, nftables, or services |
|
||||
| `network_sniffer.py` | owns capture sessions and AF_PACKET sockets | in-process session map and pre-DB buffer | raw sockets, reader threads |
|
||||
| `packet_tracker.py` | merges capture and telemetry observations | bounded in-memory pending entries | asynchronous database persistence |
|
||||
| `database.py` | packet upsert/retrieval and SQL analysis | PostgreSQL `packets` table | WebSocket publication after single-row upserts |
|
||||
| `tshark_manager.py` | optional application-protocol enrichment | worker and metadata caches | `tshark` subprocesses/threads |
|
||||
| `bridge_telemetry.py` and `ebpf_bridge_events.py` | bridge tc/eBPF event collection | subprocess state and event queue | compiles/attaches tc programs |
|
||||
| `bridge_link_state_manager.py` | optionally propagates member failure/recovery state | watcher registry | link and Ethernet-profile changes |
|
||||
|
||||
## Shared runtime state
|
||||
|
||||
`shared_objects.py` intentionally holds process-wide references rather than using
|
||||
request-scoped dependency injection:
|
||||
|
||||
- `db`: initialized `DatabasePool`, or `None` after shutdown.
|
||||
- `web_loop`: FastAPI event loop used when worker threads need to schedule work.
|
||||
- `broadcaster`: packet update broadcaster.
|
||||
- `network_broadcaster`: network-state update broadcaster.
|
||||
|
||||
Endpoints that require the database return HTTP 503 when `shared_objects.db` is
|
||||
unavailable. Worker components should tolerate the DB not being ready by buffering
|
||||
or logging failure, rather than assuming the application has fully started.
|
||||
|
||||
## Router mounting
|
||||
|
||||
| Router module | Prefix added by `main.py` | Router-local prefix | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| `network_api` | `/network` | none | `/api/network/...` |
|
||||
| `sniffer_api` | `/sniffer` | none | `/api/sniffer/...` |
|
||||
| `packet_api` | `/packets` | none | `/api/packets/...` |
|
||||
| `analysis_api` | `/analysis` | none | `/api/analysis/...` |
|
||||
| `nft_manager` | none | `/firewall` | `/api/firewall/...` |
|
||||
| `packet_scripting_api` | `/scripts` | `/scripts` | `/api/scripts/scripts/...` |
|
||||
|
||||
The last row reflects the current code exactly. It is worth preserving this fact in
|
||||
examples until the duplicated prefix is deliberately changed.
|
||||
82
documentation/backend/capture-pipeline.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Packet capture and correlation pipeline
|
||||
|
||||
## Capture modes
|
||||
|
||||
A sniffer session targets exactly one interface or bridge.
|
||||
|
||||
- **Interface target:** an `AF_PACKET` raw socket is opened on that interface;
|
||||
its effective mode is always `af_packet`.
|
||||
- **Bridge target with `af_packet`:** the bridge's member interfaces are captured
|
||||
with raw sockets.
|
||||
- **Bridge target with `tc_ebpf` (default):** no raw socket is opened for bridge
|
||||
ports. `BridgeTelemetryManager` manages an eBPF/tc helper that exports ingress
|
||||
raw data and egress/drop verdict-related events.
|
||||
- **Benchmark mode:** preserves session accounting but skips the normal userspace
|
||||
packet processing path, allowing capture-overhead measurements.
|
||||
|
||||
Sessions have UUIDs and record their label, target type, mode, snapshot of bridge
|
||||
ports, capture interfaces, socket map, thread, and stop event. Stopping by session
|
||||
ID is preferred. A target-specific stop finds matching sessions; an unqualified
|
||||
stop stops every session.
|
||||
|
||||
## End-to-end lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Capture socket or tc/eBPF
|
||||
participant N as network_sniffer
|
||||
participant T as tshark manager
|
||||
participant P as PacketTracker
|
||||
participant D as DatabasePool
|
||||
participant W as packet WebSocket
|
||||
C->>N: frame / telemetry event
|
||||
N->>N: parse headers, identity, observation metadata
|
||||
N->>T: lookup or schedule enrichment
|
||||
N->>P: capture observation
|
||||
C->>P: ingress/egress/verdict telemetry
|
||||
P->>P: correlate, merge and finalize record
|
||||
P->>D: upsert packet
|
||||
D->>W: publish normalized row
|
||||
```
|
||||
|
||||
`network_sniffer.parse_packet` parses Scapy packet objects, while
|
||||
`parse_packet_bytes` supports raw data. It extracts link, network, and transport
|
||||
fields; adds capture session/observation data; calculates or obtains correlation
|
||||
identifiers; and hands observations to `PacketTracker`. If the shared database or
|
||||
event loop is not yet usable, records are retained in a bounded in-memory buffer;
|
||||
`drain_buffer_to_shared_db` flushes it at application startup.
|
||||
|
||||
## Identity and merging
|
||||
|
||||
`packet_identity.build_packet_uid` makes a stable hash-based fallback identity
|
||||
from normalized packet fields. `packet_mark` decodes the shared skb-mark layout:
|
||||
it normalizes an observed mark, extracts a packet ID, and extracts a verdict hint.
|
||||
Kernel-mark identity is preferred when present; the hash fallback keeps capture and
|
||||
telemetry correlation possible when it is not.
|
||||
|
||||
`PacketTracker` aggregates observations in a bounded dictionary. It deduplicates
|
||||
observations, keeps capture and telemetry provenance, combines ingress/egress and
|
||||
verdict timing, and delays finalization briefly so companion events can arrive.
|
||||
It writes finalized or aged dirty records in batches, retries failed persistence
|
||||
with bounded exponential backoff, discards pending records for stopped interfaces,
|
||||
and exposes a debug snapshot. Its limits and timings are all configured through
|
||||
`BACKEND_PACKET_TRACKER_*` settings.
|
||||
|
||||
## tshark enrichment
|
||||
|
||||
`TsharkManager` starts one long-lived `tshark` process per enabled interface. It
|
||||
reads JSON output in a thread, derives protocol stacks, HTTP/TLS/DNS information,
|
||||
TCP flags, and stream context, then caches matching data for a configurable time
|
||||
window. The capture parser can use a heuristic immediately and the manager can
|
||||
backfill metadata or stream context into already stored rows. tshark is optional in
|
||||
the logical pipeline but enabled by default; a missing executable or worker failure
|
||||
is logged and does not stop capture.
|
||||
|
||||
## Realtime delivery
|
||||
|
||||
`PacketBroadcaster` maintains a bounded asyncio queue per subscriber. A successful
|
||||
single-row database upsert serializes the row and publishes it to subscribers.
|
||||
Slow consumers lose queued messages when their individual queue is full rather than
|
||||
blocking the capture or database path. `/api/packets/ws/packets` is therefore a
|
||||
live-update channel, not a lossless event log; clients should retrieve history over
|
||||
REST and use the WebSocket for incremental updates.
|
||||
73
documentation/backend/configuration.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Configuration and deployment
|
||||
|
||||
## Runtime dependencies
|
||||
|
||||
The service runs with Python 3.11 in the supplied Dockerfile and starts Uvicorn as
|
||||
`src.main:app` on port 8000 with reload enabled. Python dependencies include
|
||||
FastAPI/Pydantic, asyncpg, pyroute2, Scapy, pip-nftables, multipart handling, and
|
||||
WebSocket support. The image installs build tools, libpcap development headers,
|
||||
pkg-config, and `tshark`.
|
||||
|
||||
The host also needs facilities that a minimal application container normally does
|
||||
not have: a reachable PostgreSQL database, Linux network namespace permissions,
|
||||
raw-socket capability, access to `ip`/pyroute2 netlink operations, nftables and
|
||||
appropriate capability, `ethtool` where profile/reset functions are used,
|
||||
systemd/systemctl for scripts, and BCC/eBPF/tc tooling for `tc_ebpf` capture.
|
||||
|
||||
## Environment variables
|
||||
|
||||
All settings are loaded once by `src.config.load_settings`. Empty values use their
|
||||
default. Boolean true values are `1`, `true`, `yes`, or `on` (case-insensitive).
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `BACKEND_DB_DSN` | `postgresql://mitm_user:mitm_password@localhost:5432/mitm_db` | PostgreSQL connection string. |
|
||||
| `BACKEND_LOG_LEVEL` | `DEBUG` | Python logging level. |
|
||||
| `BACKEND_DB_POOL_MIN_SIZE` / `MAX_SIZE` | `1` / `5` | asyncpg pool bounds. |
|
||||
| `BACKEND_BROADCAST_QUEUE_MAXSIZE` | `1024` | Per-WebSocket broadcast queue size. |
|
||||
| `BACKEND_PACKET_TRACKER_FINALIZE_DELAY_SECONDS` | `0.25` | Wait for related observations before finalizing. |
|
||||
| `BACKEND_PACKET_TRACKER_RETENTION_SECONDS` | `10.0` | Pending-entry retention. |
|
||||
| `BACKEND_PACKET_TRACKER_MIN_FLUSH_INTERVAL_SECONDS` | `0.05` | Minimum persistence flush interval. |
|
||||
| `BACKEND_PACKET_TRACKER_PERSIST_TIMEOUT_SECONDS` | `2.0` | One persistence attempt timeout. |
|
||||
| `BACKEND_PACKET_TRACKER_BATCH_PERSIST_TIMEOUT_SECONDS` | `10.0` | Batch persistence timeout. |
|
||||
| `BACKEND_PACKET_TRACKER_PERSIST_RETRY_BACKOFF_SECONDS` / `MAX_SECONDS` | `0.25` / `5.0` | Retry backoff bounds. |
|
||||
| `BACKEND_PACKET_TRACKER_ERROR_LOG_INTERVAL_SECONDS` | `5.0` | Failure-log throttling interval. |
|
||||
| `BACKEND_PACKET_TRACKER_FLUSH_BATCH_SIZE` | `500` | Maximum batch size; clamped to at least 1. |
|
||||
| `BACKEND_PACKET_TRACKER_MAX_ENTRIES` | `50000` | Bounded in-memory correlation capacity; clamped to at least 1. |
|
||||
| `BACKEND_PACKET_TRACKER_MAX_PERSIST_FAILURES` | `3` | Failure threshold; clamped to at least 1. |
|
||||
| `BACKEND_PACKET_TRACKER_MAX_DIRTY_AGE_SECONDS` | `60.0` | Maximum age before dirty data must be flushed. |
|
||||
| `BACKEND_PACKET_TRACKER_STOP_JOIN_TIMEOUT_SECONDS` | `2.0` | Tracker thread join timeout. |
|
||||
| `BACKEND_PACKET_TRACKER_REJECT_CORRELATION_WINDOW_SECONDS` | `1.0` | Rejection-event matching window. |
|
||||
| `BACKEND_SNIFFER_BUFFER_CAPACITY` | `20000` | Pre-DB capture buffer capacity. |
|
||||
| `BACKEND_SNIFFER_SOCKET_RCVBUF_BYTES` | `4194304` | Requested raw-socket receive buffer. |
|
||||
| `BACKEND_SNIFFER_SELECTOR_TIMEOUT_SECONDS` | `1.0` | Reader select timeout. |
|
||||
| `BACKEND_SNIFFER_RECV_BYTES` | `65536` | Maximum raw receive length. |
|
||||
| `BACKEND_SNIFFER_BUFFER_DRAIN_INTERVAL_SECONDS` | `5.0` | Buffered-record drain frequency. |
|
||||
| `BACKEND_SNIFFER_THREAD_JOIN_TIMEOUT_SECONDS` | `2.0` | Session reader join timeout. |
|
||||
| `BACKEND_BRIDGE_BPF_BUILD_DIR` | `/tmp/mitm-bpf` | eBPF build artifacts directory. |
|
||||
| `BACKEND_BRIDGE_TELEMETRY_RAW_SAMPLE_EVERY` / `META_SAMPLE_EVERY` | `1` / `1` | Raw/meta sampling rates; zero is allowed. |
|
||||
| `BACKEND_BRIDGE_TELEMETRY_INGRESS_PERF_PAGES` / `META_PERF_PAGES` | `256` / `128` | eBPF perf-buffer page counts. |
|
||||
| `BACKEND_BRIDGE_TELEMETRY_EVENT_QUEUE_MAXSIZE` | `20000` | Telemetry event queue cap. |
|
||||
| `BACKEND_BRIDGE_TELEMETRY_QUEUE_RECOVERY_SIZE` | `1000` | Queue recovery threshold. |
|
||||
| `BACKEND_BRIDGE_TELEMETRY_DROP_LOG_INTERVAL_SECONDS` | `5.0` | Telemetry-drop log throttling. |
|
||||
| `BACKEND_BRIDGE_LINK_STATE_THREAD_JOIN_TIMEOUT_SECONDS` | `2.0` | Link watcher join timeout. |
|
||||
| `BACKEND_BRIDGE_LINK_STATE_FAILURE_HOLDOFF_SECONDS` / `RECOVERY_HOLDOFF_SECONDS` | `0.75` / `1.0` | Delay before propagating failure/recovery. |
|
||||
| `BACKEND_BRIDGE_LINK_STATE_DEGRADED_RECHECK_SECONDS` | `0.5` | Degraded-link polling period. |
|
||||
| `BACKEND_TELEMETRY_PROCESS_STOP_TIMEOUT_SECONDS` / `READER_JOIN_TIMEOUT_SECONDS` | `3.0` / `2.0` | Telemetry subprocess shutdown limits. |
|
||||
| `BACKEND_TSHARK_ENABLED` | `true` | Enables tshark worker management. |
|
||||
| `BACKEND_TSHARK_DISPLAY_FILTER` | empty | Optional tshark display filter. |
|
||||
| `BACKEND_TSHARK_TRY_HEURISTIC_FIRST` | `true` | Applies local heuristic before tshark match. |
|
||||
| `BACKEND_TSHARK_CACHE_TTL_SECONDS` | `5.0` | Enrichment cache lifetime. |
|
||||
| `BACKEND_TSHARK_MATCH_WINDOW_MS` | `5000` | Capture-to-tshark matching window. |
|
||||
| `BACKEND_TSHARK_READER_JOIN_TIMEOUT_SECONDS` / `PROCESS_STOP_TIMEOUT_SECONDS` | `2.0` / `3.0` | tshark shutdown limits. |
|
||||
|
||||
## Operational safeguards
|
||||
|
||||
Run the API behind an authenticated, access-controlled boundary. The configured
|
||||
CORS policy currently permits every origin, method, and header; it is convenient
|
||||
for development but should not be treated as an authorization control. Keep DB
|
||||
credentials out of version control and use a production-specific DSN.
|
||||
|
||||
Before starting capture, verify target interface/bridge names and ensure recovery
|
||||
access to the host. Before using firewall or script endpoints, snapshot the nft
|
||||
ruleset and understand which systemd units and filesystem paths are in scope.
|
||||
65
documentation/backend/data-and-analysis.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Packet data, persistence, and analysis
|
||||
|
||||
## Packet record
|
||||
|
||||
`Models/packets.py` defines the normalized `PacketDBModel` returned by packet
|
||||
history APIs. It represents one correlated packet record, not necessarily one raw
|
||||
capture callback. A record may combine several observations.
|
||||
|
||||
| Field group | Fields | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Identity | `id`, `timestamp`, `updated_at`, `correlation_key`, `correlation_source`, `packet_id`, `packet_uid`, `skb_mark` | Database identity and the evidence used to correlate capture/telemetry data. |
|
||||
| Path | `capture_iface`, `ingress_if`, `egress_if`, `capture_session_id`, `capture_sources` | Where and how it was observed. |
|
||||
| Link/network/transport | MACs, EtherType, IP protocol, IPs, ports, VLAN, length | Parsed packet headers. Raw numeric values are retained beside human-readable names. |
|
||||
| Application enrichment | `flow_id`, app protocol/master protocol/category/confidence/hostname/encryption/risk, `dpi_metadata` | tshark-derived context when available. |
|
||||
| Evidence | `raw_b64`, `raw_present`, capture/telemetry metadata and `capture_observations` | Raw bytes and provenance; may be absent. |
|
||||
| Outcome | `verdict`, reason/confidence, ingress/egress/verdict timestamps | Forwarding outcome inferred from telemetry. |
|
||||
|
||||
Each `PacketObservationModel` identifies whether its contribution was `capture` or
|
||||
`telemetry`, the source, interface, timestamp, event type, capture mode, session,
|
||||
and optional reason. Consumers should not assume that every optional field exists:
|
||||
AF_PACKET, tc/eBPF, and enrichment sources provide different evidence.
|
||||
|
||||
## DatabasePool
|
||||
|
||||
`DatabasePool` is an asyncpg wrapper initialized from `BACKEND_DB_DSN`. Startup
|
||||
makes compatibility changes to an existing `packets` table: fills missing
|
||||
timestamps, sets timestamp defaults/non-null constraints, adds
|
||||
`capture_observations` JSONB if needed, and creates an index on `packet_uid`.
|
||||
|
||||
Writes use an upsert keyed by `correlation_key`. `upsert_packet` returns a row and
|
||||
publishes it to the packet broadcaster; `upsert_packets` is a batched performance
|
||||
path and does not individually publish rows. Before writing, it normalizes JSON
|
||||
fields and attaches derived protocol, flow, and analysis fields.
|
||||
|
||||
Read/analysis methods are:
|
||||
|
||||
- `fetch_latest(limit)` for history.
|
||||
- `backfill_packet_metadata` and `backfill_stream_metadata` for late tshark data.
|
||||
- `infer_interface_hosts`, `infer_interface_host_protocols`, and
|
||||
`infer_interface_protocol_paths` for topology/protocol views.
|
||||
- `analyze_conversations` and `fetch_conversation_flow_detail` for directional
|
||||
communication views.
|
||||
- `analyze_host_intelligence`, `analyze_discovery_activity`, and
|
||||
`analyze_anomalies` for investigation aids.
|
||||
- `clear_all_packets(reset_identity)` for destructive history cleanup.
|
||||
|
||||
## Analysis interpretation
|
||||
|
||||
The analysis API runs SQL aggregations over what the system captured. It infers
|
||||
attachment from traffic evidence, groups protocol paths and conversations, and
|
||||
derives host/service/hostname hints. Its anomaly queries rank plausible scan,
|
||||
beacon, rare-service, TCP-reset, and drop-heavy patterns. These are leads for an
|
||||
operator—not assertions of a network's real topology, attribution, or malicious
|
||||
intent. Missing capture events, encrypted traffic, NAT, asymmetric paths, and
|
||||
limits change the output.
|
||||
|
||||
## Enumerations and configuration models
|
||||
|
||||
`Models/etherType.py` provides a string-valued `EtherTypeEnum` and
|
||||
`ethertype_from_int`; `Models/ip_protocol.py` provides `IPProtocolEnum` and
|
||||
`protocol_from_number`. They turn numeric protocol fields into readable labels
|
||||
while keeping raw values. `Models/netplan.py` provides Pydantic schemas for
|
||||
nameservers, Ethernet settings, bridge settings, and a full Netplan-style network
|
||||
configuration. These models are reusable schemas; they are not a substitute for
|
||||
applying a Netplan configuration in the currently mounted API.
|
||||
90
documentation/backend/host-integration.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Linux host integration and side effects
|
||||
|
||||
## Network and bridge control
|
||||
|
||||
`api/network_api.py` retains process-wide pyroute2 `IPRoute` and `NDB` objects.
|
||||
It reads addresses, link flags, routes and bridge membership through netlink, and
|
||||
uses NDB/pyroute2 to create or remove bridges. Resetting interfaces executes
|
||||
`ethtool` and changes MTU/profile values. These operations affect the host's live
|
||||
connectivity; API errors must be treated as operational failures, not merely input
|
||||
validation failures.
|
||||
|
||||
`utilities/interface_bridge_helpers.py` is the low-level read layer. It checks
|
||||
interface presence/up state, reads sysfs operational/carrier/admin/MTU values,
|
||||
obtains Ethernet profile data using `ethtool`, caches profile data, and reads bridge
|
||||
members from sysfs. It deliberately supplies best-effort information when a driver
|
||||
or platform cannot report every property.
|
||||
|
||||
`bridge_link_state_manager.py` owns optional event-driven bridge watchers. Each
|
||||
watcher tracks Ethernet profile and member readiness, uses failure and recovery
|
||||
holdoffs to avoid flapping, and adjusts selected peer state so an inline bridge
|
||||
reacts coherently to member link loss. `BridgeLinkStateManager` indexes watchers,
|
||||
enables/disables them, reports individual/all status, and stops all during shutdown.
|
||||
|
||||
## eBPF/tc telemetry
|
||||
|
||||
`bridge_telemetry.py` manages the lifecycle of the telemetry helper. Its
|
||||
`update_sessions` method reconciles currently requested bridge ports with the
|
||||
subprocess; `stop` terminates it and `get_debug_snapshot` provides operator
|
||||
diagnostics. It does not itself parse kernel events.
|
||||
|
||||
`ebpf_bridge_events.py` is the helper process. It builds BPF source, attaches tc
|
||||
programs to requested interfaces, reads perf events, and writes JSON-safe event
|
||||
payloads. Events cover ingress raw capture plus egress/drop metadata, including
|
||||
interfaces, MAC/IP information, packet identity, event/reason names, and timing.
|
||||
It cleans existing clsact qdiscs/program attachment as part of setup/cleanup. This
|
||||
requires an appropriate kernel, BCC Python bindings/toolchain, tc, and privileges.
|
||||
|
||||
`tools/ebpf/mark_packet_id.c` is related kernel-side support for packet marking;
|
||||
the mark is decoded by `utilities/packet_mark.py` and used in tracker correlation.
|
||||
|
||||
## nftables
|
||||
|
||||
The mounted `api/nft_manager.py` uses `pip-nftables` to list JSON/text rulesets,
|
||||
normalize them into stable table/chain/rule models, parse rule priorities/text, and
|
||||
delete a rule by handle. Its raw-command endpoint forwards textual nft commands.
|
||||
It therefore needs capability to inspect and change the host nftables ruleset.
|
||||
|
||||
Two alternative implementations exist but are currently unmounted:
|
||||
|
||||
- `api/nft_api.py` is bridge-family oriented. It models meta, Ethernet, IP, port,
|
||||
conntrack, verdict, reject, log, and raw expressions; can generate previews,
|
||||
list rules with authoritative handles, add/delete/update rules, and uses the
|
||||
`nft` CLI.
|
||||
- `api/nftables_api.py` is a stateless typed replacement API. It models matches and
|
||||
actions, chooses a pyroute2 binding when viable or a CLI wrapper otherwise,
|
||||
ensures table/chain presence, reconstructs readable rules, and replaces a chain's
|
||||
ruleset. Its own source warns that a running asyncio loop may force CLI fallback.
|
||||
|
||||
Do not mount more than one firewall router without an explicit API versioning and
|
||||
conflict review: all manipulate shared kernel state and have overlapping concepts.
|
||||
|
||||
## NFQUEUE script services
|
||||
|
||||
`api/packet_scripting_api.py` manages executable Python scripts. It makes these
|
||||
directories at import time: `/srv/fw-scripts`, `/srv/fw-scripts/venvs`, and the
|
||||
repository's `backend/example_scripts`. Scripts are named `<name>.py`; requirements
|
||||
are `<name>-requirements.txt`; virtual environments are per-script. Units use the
|
||||
deterministic name `fw-script-<name>-q<queue>.service` and are written under
|
||||
`/etc/systemd/system`.
|
||||
|
||||
The module discovers services through `systemctl`, writes/parses unit `ExecStart`,
|
||||
runs `daemon-reload`, starts/stops/enables/disables units, creates virtualenvs, and
|
||||
uses pip to install user-provided requirements. Startup can copy protected example
|
||||
scripts and optionally deploy them from `<name>.deploy.json`. This API is a remote
|
||||
code/service-management surface and requires strict authentication plus host-level
|
||||
least privilege.
|
||||
|
||||
## External subprocesses
|
||||
|
||||
| Integration | Commands/facility | Used by |
|
||||
| --- | --- | --- |
|
||||
| tshark | long-lived `tshark` subprocesses | DPI enrichment |
|
||||
| nftables | `nft` CLI and/or pip-nftables bindings | firewall APIs |
|
||||
| Ethernet control | `ethtool` | interface profile/reset |
|
||||
| system services | `systemctl`, virtualenv, pip | script lifecycle |
|
||||
| BPF/tc | BCC, tc, qdisc/program attachment | bridge telemetry |
|
||||
|
||||
Failures are generally logged and translated to endpoint failures or degraded
|
||||
capture. Operators should collect `/api/sniffer/debug`, service logs, nftables
|
||||
state, and interface state when investigating a problem.
|
||||
233
documentation/backend/sniffing.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Technical reference: packet sniffing modes
|
||||
|
||||
This document specifies the implemented capture behavior in `network_sniffer.py`,
|
||||
`bridge_telemetry.py`, `ebpf_bridge_events.py`, and `packet_tracker.py`. It makes a
|
||||
deliberate distinction between observed facts and inferred forwarding results.
|
||||
|
||||
## Session model and mode selection
|
||||
|
||||
A capture session has a UUID and targets exactly one interface or exactly one
|
||||
bridge. A bridge is expanded once with `get_bridge_ports_once`; its member list is
|
||||
a creation-time snapshot. Later bridge membership changes are not added to the
|
||||
existing session. Session state includes target label/type, effective mode, benchmark
|
||||
flag, port snapshot, AF_PACKET sockets, optional reader thread, stop event, and
|
||||
benchmark counters.
|
||||
|
||||
| Request | Effective mode | Capture source | Path/outcome evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| Interface, any requested mode | `af_packet` | One raw socket on the interface | Packet-socket type labels an outgoing copy as egress; no kernel verdict telemetry. |
|
||||
| Bridge, `af_packet` | `af_packet` | One raw socket per snapshot bridge port | Two matching port observations can infer forwarding. |
|
||||
| Bridge, `tc_ebpf` (default) | `tc_ebpf` | One tc/eBPF helper across snapshot bridge ports | TC ingress/egress and skb-free/drop events, normally matched by skb mark. |
|
||||
| Any mode with benchmark enabled | Same hook/socket setup | Counted but not processed | No parsing, enrichment, DB write, or WebSocket event. |
|
||||
|
||||
Interface targets always use AF_PACKET. The TC/eBPF mode is only selected for a
|
||||
bridge target. A stop by session ID is the safest selector. Stopping a session also
|
||||
discards pending tracker entries relating to its interfaces, so unpersisted data can
|
||||
be lost deliberately at shutdown.
|
||||
|
||||
Multiple sessions may overlap on an interface. This is not an independent-capture
|
||||
guarantee: the TC manager maps an interface to several sessions but assigns raw
|
||||
ingress parsing to the first sorted session ID.
|
||||
|
||||
## AF_PACKET capture
|
||||
|
||||
### Socket behavior
|
||||
|
||||
For each capture interface the service opens `AF_PACKET` / `SOCK_RAW` with protocol
|
||||
`htons(0x0003)` (`ETH_P_ALL`), requests the configured receive buffer (default
|
||||
4 MiB), best-effort requests `TPACKET_V3`, binds to `(ifname, 0)`, and makes the
|
||||
socket non-blocking. Failure to set the buffer or TPACKET version is non-fatal.
|
||||
Failure to create or bind leaves the interface uncaptured; session creation can still
|
||||
complete. This requires raw-socket privilege, commonly `CAP_NET_RAW`.
|
||||
|
||||
One daemon reader thread is started only when the session has sockets. It uses a
|
||||
selector, receives at most `BACKEND_SNIFFER_RECV_BYTES` bytes per event (default
|
||||
65,536), stamps the frame with userspace UTC receive time, creates Scapy `Ether`,
|
||||
and calls the common parser. `ENODEV`, `ENETDOWN`, and `EBADF` close the affected
|
||||
socket; it is not reopened in that session. The thread periodically attempts a
|
||||
pre-DB-buffer drain during selector idle time.
|
||||
|
||||
### Direction and bridge inference
|
||||
|
||||
Packet-socket address metadata is used only as follows: `PACKET_OUTGOING` (normally
|
||||
4) becomes `path_role: egress`; every other packet type becomes `path_role:
|
||||
ingress`. This is a packet-socket perspective, not proof of a Linux bridge decision.
|
||||
|
||||
For a bridge session, the tracker groups AF_PACKET observations by session ID. It
|
||||
uses an explicit ingress observation if available, otherwise the earliest one. It
|
||||
prefers an explicit egress observation on a different port, otherwise a later
|
||||
different-port observation. If one correlated packet is seen on at least two ports,
|
||||
the tracker records:
|
||||
|
||||
```text
|
||||
verdict = accept
|
||||
verdict_reason = bridge-af_packet-forwarded-observed
|
||||
verdict_confidence = medium
|
||||
```
|
||||
|
||||
This means matching evidence was observed on two bridge ports. It does not prove a
|
||||
particular kernel forwarding verdict and can be affected by duplicate copies, loops,
|
||||
or fallback-identity collisions. A single-interface AF_PACKET record has no terminal
|
||||
verdict from AF_PACKET itself.
|
||||
|
||||
### AF_PACKET implications
|
||||
|
||||
AF_PACKET provides full observed frame bytes without BCC or tc changes and is the
|
||||
only interface-capture mode. It neither alters packets nor controls forwarding. It
|
||||
also has no definitive drop visibility, reports userspace rather than kernel event
|
||||
time, can observe local/outgoing copies, and can lose traffic under socket/userspace
|
||||
load. The full-frame Scapy parse, tshark lookup, tracking and persistence path makes
|
||||
it more expensive than sampled telemetry.
|
||||
|
||||
## TC/eBPF bridge capture
|
||||
|
||||
### Collector lifecycle and destructive qdisc behavior
|
||||
|
||||
Bridge sessions in `tc_ebpf` mode are aggregated into one helper process. Any change
|
||||
to the active *interface set* stops the helper and recreates it for the new set;
|
||||
there is a capture gap during that restart. The helper attaches direct-action
|
||||
`BPF.SCHED_CLS` programs at TC ingress (`ffff:fff2`, handle `:20`) and egress
|
||||
(`ffff:fff3`, handle `:30`) to every bridge **member interface**, not the bridge
|
||||
device itself.
|
||||
|
||||
Before attachment the helper runs `tc qdisc del dev <iface> clsact` (ignoring its
|
||||
result), then `tc qdisc add dev <iface> clsact`. It deletes `clsact` again for every
|
||||
instrumented interface at helper shutdown and after an attachment failure.
|
||||
|
||||
> Starting, restarting, failing, or stopping TC/eBPF capture can remove pre-existing
|
||||
> clsact qdiscs and their filters. Do not use it on interfaces with unrelated TC
|
||||
> configuration unless coexistence and recovery are explicitly managed.
|
||||
|
||||
The BCC Python runtime, a compatible kernel, BPF/tracepoint access, TC and netlink
|
||||
privileges are required. Session creation does not wait for a collector health
|
||||
acknowledgement, so a successful start response is not proof that BPF attached.
|
||||
|
||||
### Kernel event generation
|
||||
|
||||
The helper opens a raw-ingress perf buffer and a metadata perf buffer. The ingress
|
||||
TC program creates an skb mark only when it is zero, using the low 28 bits of
|
||||
`bpf_ktime_get_ns()` and replacing zero with one. It preserves any existing nonzero
|
||||
mark. It extracts Ethernet addresses, EtherType, a single 802.1Q/802.1AD VLAN ID,
|
||||
ARP IPv4 addresses, and IPv4/IPv6 addresses with TCP/UDP ports. IPv6 extension
|
||||
headers are not traversed; the base next-header is used as protocol.
|
||||
|
||||
The egress TC program never creates a mark. It exports metadata only for marked
|
||||
packets. The `skb:kfree_skb` tracepoint reads the linear skb representation and
|
||||
exports a drop event only for marked skbs whose device is a selected interface.
|
||||
The emitted payload contains userspace and kernel-monotonic timestamps, interface,
|
||||
mark, length, parsed L2–L4 fields, and event type. Drop events add a numerical
|
||||
reason and `skb_drop_reason_<n>` label. Ingress events may contain `raw_b64`; egress
|
||||
and drop events do not.
|
||||
|
||||
A kfree_skb event is evidence that a marked skb was freed in the kernel context. It
|
||||
is not automatically evidence that nftables caused the outcome; interpret the
|
||||
reason code in the context of kernel behavior and other instrumentation.
|
||||
|
||||
### Sampling
|
||||
|
||||
Raw and metadata sampling are independent settings.
|
||||
|
||||
| Value | Effect |
|
||||
| --- | --- |
|
||||
| `0` | Never emits that sample category. |
|
||||
| `1` | Emits every marked packet in that category. |
|
||||
| `N > 1` | Emits when `skb_mark % N == 0`. |
|
||||
|
||||
At ingress, a raw-selected packet emits a raw event; only a packet not chosen for
|
||||
raw can emit an ingress metadata event. Egress and drop use metadata sampling only.
|
||||
Therefore raw-enabled/meta-disabled capture stores sampled ingress frame records
|
||||
without egress/drop visibility; raw-disabled/meta-enabled capture produces
|
||||
metadata-only rows without raw bytes. Both enabled does not make raw and metadata
|
||||
populations identical.
|
||||
|
||||
Sampling uses the entire existing skb mark. The documented mark layout reserves
|
||||
bits 0–27 for packet ID and upper bits for drop/reject hints. This capture program
|
||||
creates only the low-28-bit value for previously zero marks; it does not set verdict
|
||||
hints. Any other mark-using subsystem must coordinate its mark semantics, because
|
||||
it can change both sampling and correlation.
|
||||
|
||||
### Userspace event handling and loss
|
||||
|
||||
The manager reads JSON helper output into a bounded queue. For a non-benchmark
|
||||
ingress event with `raw_b64`, it decodes the frame and sends it into the common Scapy
|
||||
parser as source `tc_ingress_raw`, with `packet_id`, `skb_mark`, and capture mode
|
||||
`tc_ingress`. It then sends every non-benchmark ingress/egress/drop event to the
|
||||
tracker. A sampled raw ingress packet usually therefore has both a parsed capture
|
||||
observation and a telemetry observation under the same mark-derived key.
|
||||
|
||||
When the telemetry queue is full, the manager drops oldest queued events down to
|
||||
`BACKEND_BRIDGE_TELEMETRY_QUEUE_RECOVERY_SIZE`, attempts to keep the new event, and
|
||||
counts dropped events and raw payloads. Perf buffers can also lose samples before
|
||||
userspace. Neither loss mechanism is recovered. `/api/sniffer/debug` reports queue
|
||||
size, queue drops, benchmark counts, collector interfaces, and tracker statistics.
|
||||
|
||||
### TC/eBPF implications
|
||||
|
||||
This mode yields better within-host correlation and explicit TC egress evidence. A
|
||||
matching egress produces `accept`, `egress-observed`, confidence `high`; a matching
|
||||
drop produces `drop` (or mark hint), confidence `high`. Absence of egress is not
|
||||
proof of a drop: sampling, perf loss, queue loss, an uninstrumented path, teardown,
|
||||
or collector failure can all explain it. Raw bytes are ingress-only and sampled.
|
||||
|
||||
## Common parsing, enrichment, and identity
|
||||
|
||||
Both modes use `parse_packet` / `parse_packet_bytes`. The parser records Ethernet
|
||||
addresses, EtherType and VLAN, ARP operation/addressing, IPv4 ID or IPv6 base
|
||||
header, TCP sequence/acknowledgement/flags, UDP ports, ICMP/ICMPv6 type/code, and
|
||||
an embedded IPv4 tuple from eligible ICMP errors. It stores full raw frame bytes
|
||||
when supplied by AF_PACKET or sampled TC ingress.
|
||||
|
||||
tshark workers are enabled for non-benchmark capture interfaces. They may add
|
||||
application protocol, category, confidence, hostname, encryption/risk, and flow/DPI
|
||||
metadata. They are optional and asynchronous; a failure or late match does not
|
||||
discard underlying capture, and later backfill can enrich stored records.
|
||||
|
||||
The preferred identity is `pid:<packet-id>`, where the ID is bits 0–27 of skb mark.
|
||||
Without it, a SHA-1 `uid` is calculated. The Scapy fallback includes L2–L4 fields,
|
||||
IPv4 ID, ARP/ICMP fields and TCP sequence/ack/flags; eBPF metadata's fallback uses
|
||||
only the smaller L2–L4 tuple and length. Hash-only correlation is consequently a
|
||||
best-effort fallback, weaker for repeated/identical/fragmented traffic.
|
||||
|
||||
## Tracker outcomes and persistence
|
||||
|
||||
The tracker deduplicates observations, merges available fields, retains the earliest
|
||||
timestamp, and waits the configured finalization delay (default 250 ms).
|
||||
|
||||
| Evidence | Verdict | Confidence |
|
||||
| --- | --- | --- |
|
||||
| TC egress telemetry | `accept`; `egress-observed` | high |
|
||||
| TC drop telemetry | mark hint or `drop`; kernel reason / `kfree_skb` | high |
|
||||
| Matching recent TCP RST or ICMP unreachable after drop | `reject` | medium |
|
||||
| Same AF_PACKET bridge record on two ports | `accept`; forwarding observed | medium |
|
||||
| No terminal evidence before delay expires | `unknown`; `timeout` | low |
|
||||
|
||||
It asynchronously upserts batches to PostgreSQL. Entry-cap pressure, persistence
|
||||
failure/retry limits, dirty-age expiry, collector queue loss, socket loss, and
|
||||
shutdown can all cause incompleteness. A packet history or WebSocket feed is never
|
||||
a proof of lossless capture. Batch upserts also do not individually publish packet
|
||||
updates, so realtime consumers must use history reconciliation.
|
||||
|
||||
## Benchmark mode
|
||||
|
||||
Benchmark mode still creates sockets or TC hooks but bypasses normal processing.
|
||||
AF_PACKET increments received frame and byte counters. TC/eBPF increments helper
|
||||
event counters and raw-payload-event counters. It does not parse Scapy, invoke
|
||||
tshark, call the tracker, persist rows, or publish updates. AF_PACKET counters count
|
||||
socket frames; TC counters count emitted sampled events. They are not comparable as
|
||||
equal packet totals without accounting for sampling and multiple event types.
|
||||
|
||||
## Selection guidance
|
||||
|
||||
| Need | Mode | Important caveat |
|
||||
| --- | --- | --- |
|
||||
| Full raw visibility for a single interface | AF_PACKET | No definitive kernel egress/drop verdict. |
|
||||
| Full raw frames across bridge ports | Bridge AF_PACKET | High userspace work; bridge forwarding is inferred. |
|
||||
| Ingress/egress/drop evidence on a controlled bridge | TC/eBPF | Requires BPF/TC privileges and resets clsact. |
|
||||
| Reduced overhead / sampled observability | TC/eBPF sampling | Data is intentionally incomplete. |
|
||||
| Hook-overhead measurement | Benchmark mode | Counts differ between AF_PACKET and TC. |
|
||||
|
||||
Before TC/eBPF capture, inspect `tc qdisc` and filters for every target port,
|
||||
coordinate skb-mark ownership, verify BCC/kernel support, and plan recovery of the
|
||||
TC configuration. For every mode, monitor sniffer debug counters, system logs,
|
||||
capture/process health, DB persistence failures, and expected traffic rate before
|
||||
making operational or security conclusions.
|
||||
70
documentation/backend/source-reference.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Source reference
|
||||
|
||||
This index covers every Python module under `backend/src`, including helper and
|
||||
unmounted-router code. Function names prefixed with `_` are private implementation
|
||||
details; they are described by their owning module's responsibility rather than as
|
||||
separate public contracts.
|
||||
|
||||
## Application and configuration
|
||||
|
||||
| Module | Public surface and role |
|
||||
| --- | --- |
|
||||
| `main.py` | Creates FastAPI, enables permissive CORS, registers startup/shutdown handlers, provides `/hello` and `/versions`, includes live routers, and registers script lifecycle hooks. |
|
||||
| `config.py` | Parses environment strings/integers/floats/booleans; immutable `BackendSettings`; `load_settings`; module-global `settings`. See [configuration](configuration.md). |
|
||||
| `shared_objects.py` | Process-global `db`, `web_loop`, packet broadcaster, and network broadcaster references initialized by `main`. |
|
||||
|
||||
## Models
|
||||
|
||||
| Module | Public surface and role |
|
||||
| --- | --- |
|
||||
| `Models/packets.py` | `PacketObservationModel` and `PacketDBModel`, the normalized persisted/API packet schemas. |
|
||||
| `Models/ip_protocol.py` | `IPProtocolEnum` and `protocol_from_number`, translating IANA protocol numbers to labels. |
|
||||
| `Models/etherType.py` | `EtherTypeEnum` and `ethertype_from_int`, translating Ethernet type values to labels. |
|
||||
| `Models/netplan.py` | `Nameservers`, `EthernetConfig`, `BridgeConfig`, and `NetworkConfig` Pydantic schemas for Netplan-shaped network data. |
|
||||
|
||||
## API routers
|
||||
|
||||
| Module | Public surface and role |
|
||||
| --- | --- |
|
||||
| `api/network_api.py` | Network inspection, bridge create/remove, default reset, link-state watcher control, and network-state WebSocket. Holds shared `IPRoute`/`NDB`; converts netlink messages to Pydantic interface/route/bridge models; publishes state after mutations. |
|
||||
| `api/sniffer_api.py` | Pydantic start/stop/status models and endpoints. Validates one capture target and calls the capture-session API. |
|
||||
| `api/packet_api.py` | Latest packet retrieval, packet-history deletion, and packet-update WebSocket. Serialization handles database records and Pydantic values safely for JSON. |
|
||||
| `api/analysis_api.py` | Pydantic evidence/response models for attachment, protocols, paths, conversations, flow detail, hosts, discovery and anomaly views; delegates each endpoint to `DatabasePool`. |
|
||||
| `api/nft_manager.py` | **Mounted.** `NftManager` wrapper, normalized ruleset models and functions to list rules, delete by handle, and run textual nft. It parses JSON and textual output to enrich rule data. |
|
||||
| `api/packet_scripting_api.py` | **Mounted with doubled prefix.** Name/path validation, example deployment, systemd unit management, venv/pip operations, script status models, and upload/download/enable/disable/delete endpoints. |
|
||||
| `api/nft_api.py` | **Not mounted.** Bridge nftables typed expression model, command generator, handle mapping, and CRUD/preview endpoint functions. `RuleModel.only_bridge` rejects other families. |
|
||||
| `api/nftables_api.py` | **Not mounted.** Generic typed match/action models, resilient binding/CLI wrapper selection, rule reconstruction, and list/replace endpoint functions. |
|
||||
|
||||
## Capture, telemetry, and broadcasting utilities
|
||||
|
||||
| Module | Public surface and role |
|
||||
| --- | --- |
|
||||
| `network_sniffer.py` | Defines flexible `PacketInfo`; parses packet objects/bytes; opens/closes AF_PACKET sockets; owns session reader loops; coordinates telemetry; exposes `start_capture_session`, `stop_capture_session`, status and debug accessors. Legacy `*_afpacket_sniffer` functions delegate to current session functions. |
|
||||
| `utilities/packet_tracker.py` | `PacketTracker` observes capture or telemetry events, aggregates observations, schedules persistence, stops/discards state, and exposes diagnostics. The module-global tracker is the correlation entrypoint. |
|
||||
| `utilities/packet_identity.py` | Builds deterministic fallback packet UID and the minimum fields used to calculate it. |
|
||||
| `utilities/packet_mark.py` | Decodes numeric skb marks into a normalized mark, packet ID, and verdict hint according to the shared mark layout. |
|
||||
| `utilities/tshark_manager.py` | `TsharkManager` owns optional worker processes and caches. Parsing helpers safely coerce nested tshark JSON, extract protocol/HTTP/TLS/DNS/TCP data, derive stream context, and merge enrichment. Module-global `tshark_manager` is used by capture. |
|
||||
| `utilities/bridge_telemetry.py` | `BridgeTelemetryManager` starts/reconciles/stops the eBPF helper and reports subprocess/queue state. Module-global manager is invoked by sniffer lifecycle. |
|
||||
| `utilities/ebpf_bridge_events.py` | Standalone helper program: ctypes event format, BPF-source construction, tc attach/cleanup, perf callbacks, JSON output, signal handling, and `main`. |
|
||||
| `utilities/packet_broadcaster.py` | `PacketBroadcaster` manages subscriber queues. `subscribe`/`unsubscribe`, async `publish`, cross-thread `sync_publish`, and async `close` provide the WebSocket transport primitive. |
|
||||
|
||||
## Network and persistence utilities
|
||||
|
||||
| Module | Public surface and role |
|
||||
| --- | --- |
|
||||
| `utilities/interface_bridge_helpers.py` | Interface existence/up tests; sysfs readers for operational/carrier/admin/MTU state; Ethernet profile retrieval/cache; bridge-port discovery. |
|
||||
| `utilities/bridge_link_state_manager.py` | `EthernetProfile` and `MemberLinkState` data objects; `BridgeLinkStateWatcher` start/stop/status; `BridgeLinkStateManager` enable/disable/query/stop. It embodies debounce, failure, recovery, and profile propagation logic. |
|
||||
| `utilities/database.py` | `DatabasePool` initialization/closure, upsert/batch-upsert, enrichment backfills, latest-packet query, all analysis SQL, and history clearing. Internal helpers normalize values, derive protocol/flow/analysis fields, serialize outgoing rows, and classify discovery activity. |
|
||||
|
||||
## Extension points and maintenance notes
|
||||
|
||||
- New API functionality should live in an `APIRouter`, use Pydantic request and
|
||||
response models, and be explicitly included from `main.py`; otherwise it is not
|
||||
live.
|
||||
- New capture fields must be updated consistently in packet parsing, tracker merge,
|
||||
database upsert SQL, `PacketDBModel`, broadcaster serialization, and analysis
|
||||
queries where relevant.
|
||||
- Any new Linux side effect belongs in [host integration](host-integration.md),
|
||||
including required binary/capability, rollback behavior, and its API exposure.
|
||||
- If an unmounted nft router is adopted, document the migration and remove or
|
||||
version conflicting endpoints instead of silently mounting another implementation.
|
||||
23
documentation/thesis/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# LaTeX build artifacts
|
||||
*.aux
|
||||
*.bbl
|
||||
*.bcf
|
||||
*.blg
|
||||
*.fdb_latexmk
|
||||
*.fls
|
||||
*.idx
|
||||
*.ilg
|
||||
*.ind
|
||||
*.lof
|
||||
*.log
|
||||
*.lot
|
||||
*.out
|
||||
*.run.xml
|
||||
*.synctex.gz
|
||||
*.toc
|
||||
|
||||
# Latexmk / cache
|
||||
_latexmk*
|
||||
|
||||
# PDFs generated during local editing
|
||||
*.pdf
|
||||
48
documentation/thesis/00-acronyms.tex
Normal file
@@ -0,0 +1,48 @@
|
||||
\chapter*{List of Acronyms}
|
||||
\addcontentsline{toc}{chapter}{List of Acronyms}
|
||||
|
||||
\begin{acronym}[NFQUEUE] % Give the longest label here so that the list is nicely aligned
|
||||
\acro{API}{Application Programming Interface}
|
||||
\acro{ARP}{Address Resolution Protocol}
|
||||
\acro{BPF}{Berkeley Packet Filter}
|
||||
\acro{BPDU}{Bridge Protocol Data Unit}
|
||||
\acro{CA}{Certificate Authority}
|
||||
\acro{CPU}{Central Processing Unit}
|
||||
\acro{DMA}{Direct Memory Access}
|
||||
\acro{eBPF}{extended Berkeley Packet Filter}
|
||||
\acro{FDB}{Forwarding Database}
|
||||
\acro{FIB}{Forwarding Information Base}
|
||||
\acro{HTML}{HyperText Markup Language}
|
||||
\acro{HTTPS}{Hypertext Transfer Protocol Secure}
|
||||
\acro{HTTP}{Hypertext Transfer Protocol}
|
||||
\acro{IEEE}{Institute of Electrical and Electronics Engineers}
|
||||
\acro{IP}{Internet Protocol}
|
||||
\acro{IPv4}{Internet Protocol version 4}
|
||||
\acro{IPv6}{Internet Protocol version 6}
|
||||
\acro{LAN}{Local Area Network}
|
||||
\acro{LSM}{Linux Security Module}
|
||||
\acro{MAC}{Media Access Control}
|
||||
\acro{MITM}{Man-in-the-Middle}
|
||||
\acro{MTU}{Maximum Transmission Unit}
|
||||
\acro{NAPI}{New API}
|
||||
\acro{NAT}{Network Address Translation}
|
||||
\acro{NFQUEUE}{Netfilter Queue}
|
||||
\acro{NIC}{Network Interface Card}
|
||||
\acro{OSI}{Open Systems Interconnection}
|
||||
\acro{PVID}{Port VLAN Identifier}
|
||||
\acro{RSTP}{Rapid Spanning Tree Protocol}
|
||||
\acro{RSS}{Receive Side Scaling}
|
||||
\acro{SPAN}{Switched Port Analyzer}
|
||||
\acro{SSID}{Service Set Identifier}
|
||||
\acro{SSL}{Secure Sockets Layer}
|
||||
\acro{STP}{Spanning Tree Protocol}
|
||||
\acro{TAP}{Test Access Point}
|
||||
\acro{TCP}{Transmission Control Protocol}
|
||||
\acro{TLS}{Transport Layer Security}
|
||||
\acro{TTL}{Time To Live}
|
||||
\acro{UDP}{User Datagram Protocol}
|
||||
\acro{URI}{Uniform Resource Identifier}
|
||||
\acro{URL}{Uniform Resource Locator}
|
||||
\acro{VLAN}{Virtual Local Area Network}
|
||||
\acro{XDP}{eXpress Data Path}
|
||||
\end{acronym}
|
||||
18
documentation/thesis/00-commands.tex
Normal file
@@ -0,0 +1,18 @@
|
||||
\newcommand{\AES}{\textbf{\texttt{AES}}\xspace}
|
||||
|
||||
\newcommand{\subbytes}{\textbf{\texttt{SubBytes}}\xspace}
|
||||
\newcommand{\SB}{\textbf{\texttt{SB}}\xspace}
|
||||
|
||||
\newcommand{\mixcolumns}{\textbf{\texttt{MixColumns}}\xspace}
|
||||
\newcommand{\MC}{\textbf{\texttt{MC}}\xspace}
|
||||
|
||||
\newcommand{\shiftrows}{\textbf{\texttt{ShiftRows}}\xspace}
|
||||
\newcommand{\SR}{\textbf{\texttt{SR}}\xspace}
|
||||
|
||||
\newcommand{\addrk}{\textbf{\texttt{AddRoundKey}}\xspace}
|
||||
\newcommand{\ARK}{\textbf{\texttt{ARK}}\xspace}
|
||||
|
||||
\newcommand{\term}[2]{\textbf{#1:}\\#2\\}
|
||||
%\newcommand{\term}[2]{\textbf{#1:}\\\begingroup\leftskip#2\endgroup}
|
||||
|
||||
\newcommand{\cmt}[2]{\textcolor{red}{\sout{#1}#2}}
|
||||
35
documentation/thesis/00-oath.tex
Normal file
@@ -0,0 +1,35 @@
|
||||
%-----------------------------------------------------------------------
|
||||
\chapter*{Eidesstattliche Erklärung}
|
||||
%-----------------------------------------------------------------------
|
||||
|
||||
I, \theauthor, hereby declare that I have authored this master's thesis with title
|
||||
\begin{quote}
|
||||
\thetitle
|
||||
\end{quote}
|
||||
independently, that I have not used other than the declared sources / resources,
|
||||
and that I have explicitly marked all material which has been quoted either
|
||||
literally or by content from the used sources. The work was not submitted in same
|
||||
or similar form or in parts in the context of another examination yet.
|
||||
|
||||
\vspace{4em}
|
||||
|
||||
|
||||
Ich, \theauthor, versichere hiermit, dass ich meine
|
||||
Masterarbeit mit dem Thema
|
||||
\begin{quote}
|
||||
\thetitle
|
||||
\end{quote}
|
||||
selbstständig verfasst und keine anderen als die angegebenen Quellen und
|
||||
Hilfsmittel benutzt habe, wobei ich alle wörtlichen und sinngemäßen
|
||||
Zitate als solche gekennzeichnet habe. Die Arbeit wurde bisher keiner
|
||||
anderen Prüfungsbehörde vorgelegt und auch nicht veröffentlicht.
|
||||
|
||||
\vspace{4em}
|
||||
|
||||
\noindent
|
||||
\begin{minipage}{\columnwidth}
|
||||
\rule{7cm}{.1pt}\\
|
||||
\tiny{\theauthor}
|
||||
\end{minipage}
|
||||
\\[2em]
|
||||
Weimar, TBD\\
|
||||
153
documentation/thesis/00-packages.tex
Normal file
@@ -0,0 +1,153 @@
|
||||
%-----------------------------------------------------------------------
|
||||
% Packages
|
||||
%-----------------------------------------------------------------------
|
||||
|
||||
\usepackage{geometry}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage{lmodern}
|
||||
\usepackage{microtype}
|
||||
\usepackage[english]{babel}
|
||||
\usepackage[dvipsnames]{xcolor}
|
||||
\usepackage{float}
|
||||
\usepackage[noend]{algpseudocode}
|
||||
\usepackage{algorithm}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amsthm}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{listings}
|
||||
%\usepackage[numbers,sort&compress]{natbib}
|
||||
%\usepackage[zerostyle=d]{newtxtt}
|
||||
\usepackage{hyphenat}
|
||||
\usepackage{xspace}
|
||||
\usepackage{ifthen}
|
||||
\usepackage[format=hang
|
||||
%singlelinecheck=off
|
||||
]{caption}
|
||||
\usepackage{subcaption}
|
||||
\usepackage{wrapfig}
|
||||
\usepackage{booktabs} % for tables: \toprule, \midrule, \bottomrule
|
||||
\usepackage{multirow}
|
||||
\usepackage{acronym}
|
||||
\usepackage{csquotes}
|
||||
\usepackage[normalem]{ulem}
|
||||
\usepackage{scrhack}
|
||||
\usepackage[automark]{scrlayer-scrpage}
|
||||
\usepackage{siunitx}
|
||||
|
||||
\usepackage[
|
||||
sortcites,
|
||||
backend=biber,
|
||||
natbib=true,
|
||||
style=numeric,
|
||||
sorting=nyt
|
||||
]{biblatex}
|
||||
|
||||
\usepackage{hyperref}
|
||||
\usepackage{bookmark}
|
||||
\usepackage[nameinlink,noabbrev]{cleveref}
|
||||
|
||||
\usepackage{tikz}
|
||||
\usetikzlibrary{arrows.meta}
|
||||
\usetikzlibrary{positioning}
|
||||
\usetikzlibrary{decorations.pathreplacing}
|
||||
%\usetikzlibrary{keccaktree}
|
||||
|
||||
|
||||
\usepackage{fancyvrb}
|
||||
\usepackage{verbatimbox}
|
||||
%\usepackage{tgcursor}
|
||||
|
||||
|
||||
|
||||
|
||||
\colorlet{punct}{red!60!black}
|
||||
\definecolor{background}{HTML}{EEEEEE}
|
||||
\definecolor{delim}{RGB}{20,105,176}
|
||||
\colorlet{numb}{magenta!60!black}
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------
|
||||
% Listings
|
||||
% ---------------------------------------------------------------------
|
||||
|
||||
|
||||
\lstdefinestyle{verbatim}{
|
||||
basicstyle=\small\ttfamily,
|
||||
breaklines=true,
|
||||
columns=fullflexible,
|
||||
basewidth=0.5em,
|
||||
escapechar=\%,
|
||||
numbers=none,
|
||||
numbersep=none,
|
||||
captionpos=b,
|
||||
frame=none,
|
||||
escapeinside={(*}{*)},
|
||||
xleftmargin=0em
|
||||
}
|
||||
|
||||
%\lstset{%
|
||||
% language=Ada,
|
||||
% basicstyle=\footnotesize\ttfamily,
|
||||
% frame=l,
|
||||
% xleftmargin=\parindent,
|
||||
% % rulecolor=\color{blue},
|
||||
% framerule=2pt,
|
||||
% captionpos=b,
|
||||
% keywordstyle=\bfseries,
|
||||
% % stringstyle=\color{green},
|
||||
% % commentstyle=\color{gray},
|
||||
% showstringspaces=false}
|
||||
%
|
||||
|
||||
|
||||
\lstset{ %
|
||||
backgroundcolor=\color{white}, % choose the background color; you must add \usepackage{color} or \usepackage{xcolor}
|
||||
basicstyle=\footnotesize\ttfamily, % the size of the fonts that are used for the code
|
||||
breakatwhitespace=false, % sets if automatic breaks should only happen at whitespace
|
||||
breaklines=true, % sets automatic line breaking
|
||||
captionpos=b, % sets the caption-position to bottom
|
||||
commentstyle=\color{red}, % comment style
|
||||
deletekeywords={...}, % if you want to delete keywords from the given language
|
||||
escapeinside={\%*}{*)}, % if you want to add LaTeX within your code
|
||||
extendedchars=true, % lets you use non-ASCII characters; for 8-bits encodings only, does not work with UTF-8
|
||||
frame=l, % adds a frame around the code
|
||||
keepspaces=true, % keeps spaces in text, useful for keeping indentation of code (possibly needs columns=flexible)
|
||||
keywordstyle=\bfseries, % keyword style
|
||||
%language=Python, % the language of the code
|
||||
morekeywords={*,...}, % if you want to add more keywords to the set
|
||||
numbers=left, % where to put the line-numbers; possible values are (none, left, right)
|
||||
numbersep=5pt, % how far the line-numbers are from the code
|
||||
numberstyle=\tiny\color{black}, % the style that is used for the line-numbers
|
||||
rulecolor=\color{black}, % if not set, the frame-color may be changed on line-breaks within not-black text (e.g. comments (green here))
|
||||
showspaces=false, % show spaces everywhere adding particular underscores; it overrides 'showstringspaces'
|
||||
showstringspaces=false, % underline spaces within strings only
|
||||
showtabs=true, % show tabs within strings adding particular underscores
|
||||
stepnumber=1, % the step between two line-numbers. If it's 1, each line will be numbered
|
||||
stringstyle=\color{blue}, % string literal style
|
||||
tabsize=2, % sets default tabsize to 2 spaces
|
||||
title=\lstname, % show the filename of files included with \lstinputlisting; also try caption instead of title
|
||||
xleftmargin=1.5em
|
||||
}
|
||||
|
||||
\lstdefinelanguage{json}{
|
||||
basicstyle=\scriptsize\ttfamily,
|
||||
numbers=left,
|
||||
numberstyle=\scriptsize,
|
||||
stepnumber=1,
|
||||
numbersep=8pt,
|
||||
showstringspaces=true,
|
||||
breaklines=true,
|
||||
frame=none,
|
||||
numberstyle=\scriptsize\color{black},
|
||||
backgroundcolor=\color{white},
|
||||
literate=
|
||||
*{:}{{{\color{punct}{:}}}}{1}
|
||||
{,}{{{\color{punct}{,}}}}{1}
|
||||
{\{}{{{\color{delim}{\{}}}}{1}
|
||||
{\}}{{{\color{delim}{\}}}}}{1}
|
||||
{[}{{{\color{delim}{[}}}}{1}
|
||||
{]}{{{\color{delim}{]}}}}{1},
|
||||
}
|
||||
|
||||
73
documentation/thesis/00-settings.tex
Normal file
@@ -0,0 +1,73 @@
|
||||
% ---------------------------------------------------------------------
|
||||
% General Settings
|
||||
% ---------------------------------------------------------------------
|
||||
|
||||
\graphicspath{{./images/}}
|
||||
\renewcommand{\baselinestretch}{1.2}
|
||||
\setcounter{tocdepth}{1}
|
||||
\setcounter{secnumdepth}{3}
|
||||
\setlength{\parindent}{1.5em}
|
||||
\setlength{\parskip}{0pt}
|
||||
%\setlanguage{english}
|
||||
|
||||
% \renewcommand{\ttdefault}{txtt}
|
||||
|
||||
% \definecolor{myblue}{rgb}{0.0,0.37,0.69}
|
||||
% \definecolor{mygray}{rgb}{0.62,0.62,0.62}
|
||||
% \definecolor{myorange}{rgb}{0.84,0.53,0.0}
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------
|
||||
% Headings
|
||||
% ---------------------------------------------------------------------
|
||||
|
||||
%\let\Chaptermark\chaptermark
|
||||
%\def\chaptermark#1{
|
||||
% \def\Chaptername{#1}\Chaptermark{#1}
|
||||
% \markright{\chaptermarkformat\Chaptername \hfill}}
|
||||
%\let\Sectionmark\sectionmark
|
||||
%\def\sectionmark#1{
|
||||
% \def\Sectionname{#1}\Sectionmark{#1}
|
||||
% \markright{\chaptermarkformat\Chaptername \hfill
|
||||
% \sectionmarkformat\Sectionname}}
|
||||
|
||||
% ---------------------------------------------------------------------
|
||||
% Acronyms
|
||||
% ---------------------------------------------------------------------
|
||||
|
||||
% \newlist{acronyms}{description}{1}
|
||||
% \setlist[acronyms]{
|
||||
% labelwidth=4em,
|
||||
% leftmargin=4.5em,
|
||||
% % noitemsep,
|
||||
% itemindent=0pt
|
||||
% }
|
||||
|
||||
% \acsetup{
|
||||
% single=false,
|
||||
% hyperref=true,
|
||||
% long-format=\itshape,
|
||||
% list-long-format=,
|
||||
% list-type=acronyms
|
||||
% }
|
||||
|
||||
% \newcommand{\acro}[2]{
|
||||
% \DeclareAcronym{#1}{
|
||||
% short = #1,
|
||||
% long = #2
|
||||
% }}
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------
|
||||
% Example Float
|
||||
% ---------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
% ---------------------------------------------------------------------
|
||||
% Hyphenation
|
||||
% ---------------------------------------------------------------------
|
||||
|
||||
%\touchttfonts{} % hyphenation inside texttt (hyphenat package)
|
||||
|
||||
\hyphenation{Pfad-va-li-die-rung Per-for-mance}
|
||||
39
documentation/thesis/00-title.tex
Normal file
@@ -0,0 +1,39 @@
|
||||
\begin{titlepage}
|
||||
|
||||
% \linespread{1}
|
||||
% \Large
|
||||
|
||||
\noindent
|
||||
Bauhaus-Universität Weimar\\
|
||||
Faculty of Media\\
|
||||
Program Computer Science for Digital Media
|
||||
|
||||
\vspace{6em}
|
||||
|
||||
\begin{center}
|
||||
{\bfseries \sffamily \huge
|
||||
\thetitle}
|
||||
\\[2em]
|
||||
{\bfseries \sffamily \LARGE Master's Thesis}
|
||||
\end{center}
|
||||
|
||||
\vspace{12em}
|
||||
|
||||
\noindent
|
||||
\theauthor
|
||||
\hfill
|
||||
Matriculation Number: 119915\\
|
||||
born 22.09.1999 in Bad Salzungen
|
||||
|
||||
\vspace{6em}
|
||||
|
||||
\noindent
|
||||
1st~Reviewer: PD Dr. Andreas Jakoby\\
|
||||
2nd~Reviewer: TBD
|
||||
|
||||
\vspace{6em}
|
||||
|
||||
\noindent
|
||||
Date of Submission: TBD\\
|
||||
|
||||
\end{titlepage}
|
||||
306
documentation/thesis/02-preliminaries.tex
Normal file
@@ -0,0 +1,306 @@
|
||||
\chapter{Preliminaries}
|
||||
\label{chap:preliminaries}
|
||||
|
||||
In this chapter, the necessary background and foundational concepts underlying the research presented in this thesis are introduced. First, the theoretical frameworks and methodologies guiding the approach are discussed, followed by an overview of the key technologies and tools used in this work. The chapter is intended to establish a common understanding and provide context for the subsequent chapters, in which the specific contributions and findings of the research are presented.
|
||||
|
||||
\section[OSI Model]{\ac{OSI} Model}
|
||||
\label{sec:osi}
|
||||
|
||||
The \ac{OSI} Basic Reference Model provides a conceptual framework for describing communication between open systems in a structured and interoperable way. Instead of treating network communication as a single process, it divides it into seven layers with clearly separated responsibilities. This layered view simplifies the analysis of communication systems and provides a common terminology for discussing protocols and interfaces.
|
||||
|
||||
The \ac{OSI} model is not itself a concrete protocol suite, but rather a reference architecture. It does not prescribe which technologies must be used in practice. Instead, it provides a general structure that can be used to classify communication functions and to explain how different protocols relate to one another. Each layer offers services to the layer above while relying on the services of the layer below.
|
||||
|
||||
\subsection{Physical Layer}
|
||||
|
||||
The Physical Layer is concerned with the transmission of raw bit streams over the physical medium. It defines how signals are represented and transferred, for example over cables, optical fibers, or wireless links. It therefore forms the foundation of all higher-level communication.
|
||||
|
||||
\subsection{Data Link Layer}
|
||||
|
||||
The Data Link Layer organizes the raw bits received from the Physical Layer into structured units and supports communication between adjacent nodes on the same link. It is responsible for local addressing, medium access control, and error detection on the local transmission path.
|
||||
|
||||
\subsection{Network Layer}
|
||||
|
||||
The Network Layer enables communication beyond a single local link. It provides logical addressing and routing functions that allow data to be forwarded across interconnected networks from a source to a destination.
|
||||
|
||||
\subsection{Transport Layer}
|
||||
|
||||
The Transport Layer provides end-to-end communication services between application entities in different systems. Depending on the protocol and service model, this can include segmentation, reassembly, flow control, and error recovery.
|
||||
|
||||
\subsection{Session Layer}
|
||||
|
||||
The Session Layer is responsible for establishing, managing, and terminating communication sessions between applications. It structures the dialogue between communicating systems and can support synchronization during longer exchanges.
|
||||
|
||||
\subsection{Presentation Layer}
|
||||
|
||||
The Presentation Layer deals with the representation of data. It ensures that information exchanged between systems can be interpreted correctly even when internal data formats differ. Typical functions include formatting, translation, and related representation issues.
|
||||
|
||||
\subsection{Application Layer}
|
||||
|
||||
The Application Layer is the highest layer of the model and contains the communication functions used directly by application processes. It forms the interface between the communication system and the software that uses network services.
|
||||
|
||||
\subsection{Layered Structure and Function}
|
||||
|
||||
A key principle of the \ac{OSI} model is that each layer has a defined scope of responsibility and interacts mainly with the layers directly above and below it. This reduces complexity and supports standardization by allowing communication functions to be discussed separately while still being part of one overall architecture.
|
||||
|
||||
For the present thesis, the \ac{OSI} model is mainly used as a conceptual orientation for the discussion of communication layers and network functions. Even though the implemented system is better described using the practical \ac{TCP}/\ac{IP} stack, the \ac{OSI} structure remains useful for introducing the general principles of layered communication.
|
||||
|
||||
\section{Transparent Network Interception Models}
|
||||
\label{sec:transparent-network-interception-models}
|
||||
|
||||
\subsection{Man-in-the-Middle Terminology}
|
||||
|
||||
The term \ac{MITM} describes a communication setting in which an intermediate system is positioned between two endpoints and can observe, relay, insert, or modify messages exchanged between them \cite{conti2016mitmsurvey}. In security literature, this position is often discussed as an adversarial capability \cite{conti2016mitmsurvey}. In the present thesis, the term is used in a controlled experimental sense: the system is intentionally placed in the communication path in order to observe, correlate, and selectively manipulate traffic. The relevant distinction is therefore not only whether traffic can be observed, but also at which layer the intermediate system is inserted and whether it becomes visible to the endpoints.
|
||||
|
||||
\subsection[Passive Capture with TAP and SPAN]{Passive Capture with \ac{TAP} and \ac{SPAN}}
|
||||
|
||||
Passive monitoring systems obtain a copy of network traffic without becoming the forwarding element. A \ac{TAP} is a dedicated device inserted directly into the monitored physical link, for example between a host and a switch or between two switches. It copies the traffic that crosses this link to one or more monitoring interfaces while the original traffic continues between the connected endpoints. In contrast, \ac{SPAN}, also known as port mirroring, is configured on a switch. The monitored devices remain connected to their normal switch ports, and the switch duplicates selected ingress, egress, or bidirectional traffic from these ports to a separate monitoring port. The main difference is therefore where the traffic copy is produced. A \ac{TAP} observes the link directly at its physical position in the path, whereas \ac{SPAN} observes traffic indirectly from inside the switch forwarding and mirroring implementation. Neither mechanism gives the monitoring device direct control over the original forwarding decision, so passive capture cannot directly block or modify packets in the original stream. Zhang and Moore show that \ac{SPAN}-based monitoring can also introduce measurement artifacts, including inter-packet timings, packet reordering, and packet loss \cite{zhang2007portmirroring}.
|
||||
|
||||
\subsection{Layer-3 Routed Interception}
|
||||
|
||||
In a routed interception model, the intermediate system is part of the \ac{IP} forwarding path. An \ac{IP} router receives a packet, determines the next hop based on the destination \ac{IP} address and routing information, and transmits the packet through the selected outgoing interface \cite{rfc1812}. From the perspective of the endpoints, a routed intermediary therefore behaves as a router or gateway rather than as an Ethernet switch. Traffic must either be configured to use this system as its next hop, for example through a default gateway setting, or the surrounding network must otherwise be changed so that packets are routed through it.
|
||||
|
||||
This placement has visible protocol effects. In \ac{IPv4}, every router that forwards a packet decrements the \ac{TTL} field \cite{rfc1812}. Therefore, a routed intermediary can appear as an additional \ac{IP} hop to tools and diagnostics that inspect hop-count behavior.
|
||||
|
||||
A routed intermediary may also modify packet headers. If \ac{NAT} is used, address information is rewritten as packets traverse the translator \cite{rfc3022}. Depending on the configuration, this can affect source or destination \ac{IP} addresses, transport-layer ports, and the reverse mapping needed for return traffic \cite{rfc3022}. Even without \ac{NAT}, routed forwarding changes the Layer-2 next hop because the packet is emitted through the outgoing link selected by the routing decision \cite{rfc1812}. Consequently, the intermediary is not merely observing an existing Ethernet segment; it actively participates in \ac{IP} forwarding. This makes routed interception useful when the intermediate system is intended to enforce Layer-3 policy, apply firewalling, perform \ac{NAT}, or deliberately act as a gateway.
|
||||
|
||||
\subsection{Proxy-Based Interception}
|
||||
|
||||
Proxy-based interception moves the intermediary even higher in the stack. An \ac{HTTP} proxy terminates or relays application-layer requests rather than merely forwarding Ethernet frames. For \ac{HTTPS}, interception typically requires a \ac{TLS} proxy that presents itself as the server to the client and as the client to the external server, thereby creating two separate \ac{TLS} connections \cite{waked2018tlsinterception}. This model can expose plaintext to the proxy when the client trusts a signing \ac{CA} controlled by the proxy \cite{waked2018tlsinterception}. At the same time, it changes the end-to-end security model of \ac{TLS} \cite{decarnedecarnavalet2023tlsinterception}. Empirical studies show that \ac{HTTPS} interception can be detected through inconsistencies between \ac{HTTP} \texttt{User-Agent} information and \ac{TLS} client behavior \cite{durumeric2017httpsinterception}, and that interception appliances may introduce certificate-validation and parameter-mapping weaknesses \cite{waked2018tlsinterception}. Proxy-based interception is therefore powerful for application-layer inspection, but it is not transparent in the same sense as Layer-2 forwarding.
|
||||
|
||||
\subsection{Transparent Layer-2 Inline Bridges}
|
||||
|
||||
A transparent inline bridge occupies the forwarding path without acting as an \ac{IP} router or application proxy. Such a bridge connects network segments at the data link layer and forwards frames based on bridge state and destination \ac{MAC} addresses \cite{ieee8021q2022}. The Linux bridge implements this behavior by learning source \ac{MAC} addresses, maintaining an \ac{FDB}, and forwarding, filtering, flooding, or locally delivering frames according to the bridge configuration \cite{linuxkernelbridgedocs}. In this model, the bridge does not have to be configured as the endpoints' \ac{IP} gateway or as an application proxy, because forwarding is performed below the \ac{IP} layer.
|
||||
|
||||
\subsection{Transparency and Detectability}
|
||||
|
||||
Transparency should not be understood as complete undetectability. An inline bridge can affect latency, packet ordering, loss behavior, link-state propagation, and bridge-control behavior. If \ac{STP} is enabled, \acp{BPDU} and forwarding-delay behavior may become externally visible \cite{linuxkernelbridgedocs}. If \ac{TLS} proxying is added on top of forwarding, certificate and handshake artifacts can reveal the interception point \cite{durumeric2017httpsinterception}. The transparency goal in this thesis is therefore narrower and technical: the system should forward traffic as a Layer-2 inline bridge without introducing an additional \ac{IP} hop, without requiring endpoint proxy configuration, and without terminating application-layer sessions unless a later manipulation component explicitly does so.
|
||||
|
||||
\section{Linux Packet Filtering with \texttt{nftables}}
|
||||
\label{sec:nftables}
|
||||
|
||||
% cites noch ergänzen: nftables_manpage und nf queue noch
|
||||
|
||||
\texttt{nftables} is a framework for packet filtering and classification in Linux.
|
||||
The \texttt{nft} command-line tool is used to set up, maintain, and inspect packet-filtering and classification rules in the Linux kernel.
|
||||
The corresponding Linux kernel subsystem is called \texttt{nf\_tables} and is part of Netfilter.
|
||||
|
||||
An \texttt{nftables} ruleset is organized using several types of objects.
|
||||
In particular, \textbf{tables} are containers for chains, sets, and stateful objects, while \textbf{chains} are containers for rules.
|
||||
Tables are identified by an address family and a name.
|
||||
The supported table families are \texttt{ip}, \texttt{ip6}, \texttt{inet}, \texttt{arp}, \texttt{bridge}, and \texttt{netdev}.
|
||||
If no family is specified, the \texttt{ip} family is used by default.
|
||||
|
||||
|
||||
\subsection{Address Families and Hooks}
|
||||
\label{sec:nftables-address-families}
|
||||
|
||||
Address families determine the type of packets that \texttt{nftables} processes.
|
||||
For each address family, the kernel provides hooks at particular stages of the packet-processing path.
|
||||
These hooks invoke \texttt{nftables} when rules for the respective hooks exist.
|
||||
The \texttt{ip} family processes IPv4 packets, \texttt{ip6} processes IPv6 packets, and \texttt{inet} provides a combined IPv4/IPv6 family.
|
||||
The \texttt{arp} family handles IPv4 ARP packets, the \texttt{bridge} family handles packets traversing a bridge device, and the \texttt{netdev} family handles packets on the ingress and egress paths.
|
||||
\texttt{nftables} objects exist in address-family-specific namespaces.
|
||||
|
||||
For the IPv4, IPv6, and \texttt{inet} address families, \texttt{nftables} defines hooks at different stages of packet processing.
|
||||
The \texttt{prerouting} hook processes packets entering the system before the routing process.
|
||||
Packets delivered to the local system are processed by the \texttt{input} hook, while packets forwarded to another host are processed by the \texttt{forward} hook.
|
||||
Packets generated by local processes pass through the \texttt{output} hook, and packets leaving the system pass through the \texttt{postrouting} hook.
|
||||
The \texttt{inet} family additionally supports an \texttt{ingress} hook, which is invoked before the Layer-3 protocol handlers and therefore before \texttt{prerouting}.
|
||||
|
||||
The \texttt{bridge} address family handles Ethernet packets traversing bridge devices.
|
||||
According to the \texttt{nftables} documentation, its list of supported hooks is identical to that of the IPv4, IPv6, and \texttt{inet} families described above.
|
||||
|
||||
|
||||
\subsection{Tables, Chains, and Rules}
|
||||
\label{sec:nftables-tables-chains-rules}
|
||||
|
||||
Chains exist in two forms: base chains and regular chains.
|
||||
A base chain is an entry point for packets from the networking stack.
|
||||
A regular chain can be used as a jump target and for organizing rules.
|
||||
When a chain is created with a hook and priority, it becomes a base chain and is connected to the networking stack.
|
||||
For base chains, the chain type, hook, and priority parameters are mandatory.
|
||||
|
||||
The \texttt{filter} chain type is supported by all families and hooks.
|
||||
Other chain types have additional restrictions.
|
||||
For example, \texttt{nat} chains are supported by the \texttt{ip}, \texttt{ip6}, and \texttt{inet} families, while \texttt{route} chains are restricted to the \texttt{output} hook of those families.
|
||||
|
||||
A base chain has a priority that determines its evaluation order relative to other chains attached to the same hook.
|
||||
Lower numerical priority values are evaluated before higher values.
|
||||
The evaluation order of chains with identical priorities is undefined.
|
||||
\texttt{nftables} provides names for several standard priority values, and the priority values used by the \texttt{bridge} family differ from those used by the other families.
|
||||
|
||||
For the \texttt{bridge} family, the predefined priorities include \texttt{dstnat} with a value of $-300$ for \texttt{prerouting}, \texttt{filter} with a value of $-200$ for all hooks, \texttt{out} with a value of $100$ for \texttt{output}, and \texttt{srcnat} with a value of $300$ for \texttt{postrouting}.
|
||||
|
||||
A base chain can also specify a policy.
|
||||
The supported policies are \texttt{accept} and \texttt{drop}, with \texttt{accept} being the default.
|
||||
The policy determines what happens to packets for which the rules in the chain do not explicitly produce an acceptance or refusal.
|
||||
|
||||
Rules are contained within chains.
|
||||
According to the \texttt{nftables} documentation, rules consist of two types of components: expressions and statements.
|
||||
|
||||
|
||||
\subsection{Expressions and Statements}
|
||||
\label{sec:nftables-expressions-statements}
|
||||
|
||||
Expressions represent values.
|
||||
These values may be constants, such as network addresses and port numbers, or information obtained from a packet during ruleset evaluation.
|
||||
Expressions can be combined to construct match expressions and can also be used as arguments for operations such as NAT or packet marking.
|
||||
Each expression has a data type that determines properties including its size, parsing, representation, and compatibility with other expressions.
|
||||
|
||||
\texttt{nftables} provides, among others, meta expressions and payload expressions.
|
||||
A meta expression accesses metadata associated with a packet.
|
||||
Available metadata includes the packet length, protocol family, Layer-4 protocol, packet mark, input and output interfaces, and packet type.
|
||||
|
||||
The input and output interfaces can be accessed using \texttt{iif}, \texttt{oif}, \texttt{iifname}, and \texttt{oifname}.
|
||||
\texttt{iif} and \texttt{oif} operate on interface indices, whereas \texttt{iifname} and \texttt{oifname} operate on interface names.
|
||||
\texttt{nftables} also provides \texttt{ibrname} and \texttt{obrname}, representing the input and output bridge interface names, respectively.
|
||||
|
||||
Payload expressions refer to information contained in a packet's payload.
|
||||
For Ethernet headers, \texttt{nftables} provides expressions for the destination address (\texttt{ether daddr}), source address (\texttt{ether saddr}), and EtherType (\texttt{ether type}).
|
||||
|
||||
Further payload expressions provide access to fields of higher-layer protocols.
|
||||
For example, IPv4 expressions can access fields including source and destination addresses and the upper-layer protocol, while IPv6 expressions provide access to fields including source and destination addresses and the next-header field.
|
||||
TCP and UDP expressions provide access to source and destination ports as well as additional protocol-specific header fields.
|
||||
|
||||
Statements represent actions that are performed during rule evaluation.
|
||||
They may alter the control flow by accepting or dropping a packet or by transferring evaluation to another chain.
|
||||
Statements may also perform other actions, including logging and rejecting packets.
|
||||
nftables distinguishes between terminal and non-terminal statements.
|
||||
Terminal statements unconditionally terminate evaluation of the current rule, whereas non-terminal statements either conditionally terminate evaluation or allow it to continue.
|
||||
|
||||
|
||||
\subsection{Ruleset Evaluation and Verdicts}
|
||||
\label{sec:nftables-ruleset-evaluation}
|
||||
|
||||
Packets traverse the networking stack and are evaluated by base chains attached to the hooks they encounter.
|
||||
If multiple base chains are attached to the same hook, the chains are evaluated according to their priorities, with lower priority values evaluated first.
|
||||
Base chains may call regular chains using \texttt{jump} and \texttt{goto}, and regular chains may in turn call other regular chains.
|
||||
Chains in different tables cannot call each other.
|
||||
|
||||
nftables provides the verdict statements \texttt{accept}, \texttt{drop}, \texttt{continue}, \texttt{return}, \texttt{jump}, and \texttt{goto}.
|
||||
The \texttt{accept} and \texttt{drop} verdicts terminate chain evaluation, but their effects on subsequent processing differ.
|
||||
|
||||
An \texttt{accept} verdict terminates evaluation of the current base chain.
|
||||
Processing can subsequently continue in another base chain attached to the same hook or in a base chain attached to a later hook.
|
||||
Consequently, a packet that receives an \texttt{accept} verdict may still subsequently receive a \texttt{drop} verdict from another base chain.
|
||||
|
||||
A \texttt{drop} verdict immediately drops the packet and terminates evaluation of the ruleset.
|
||||
No further chains are evaluated, and the verdict cannot be overridden by a later \texttt{accept} verdict.
|
||||
|
||||
The \texttt{jump} statement stores the current evaluation position and continues evaluation at the beginning of another regular chain.
|
||||
When that chain ends, evaluation can return to the stored position.
|
||||
\texttt{goto} similarly transfers evaluation to another chain but does not store the current position.
|
||||
\texttt{return} terminates evaluation of the current chain and, where a stored position exists, continues evaluation from that position.
|
||||
|
||||
|
||||
\subsection{Queueing Packets to Userspace}
|
||||
\label{sec:nftables-queue}
|
||||
|
||||
In addition to issuing verdicts directly in the ruleset, nftables provides a \texttt{queue} statement.
|
||||
The \texttt{queue} statement passes a packet to userspace using the \texttt{nfnetlink\_queue} handler.
|
||||
The packet is placed into a queue identified by a 16-bit queue number.
|
||||
The default queue number is 0.
|
||||
|
||||
A userspace application receiving a queued packet can inspect it and may optionally modify it.
|
||||
The userspace application must subsequently provide either an \texttt{accept} or a \texttt{drop} verdict.
|
||||
If the packet is accepted, nftables processing resumes with the next base-chain hook rather than with the rule following the \texttt{queue} statement.
|
||||
The nftables documentation refers to the \texttt{libnetfilter\_queue} documentation for further details concerning userspace queue processing.
|
||||
|
||||
The \texttt{queue} statement can specify a single queue number, a range of queue numbers, or an expression that determines the queue number.
|
||||
Queue numbers may be computed at runtime using \texttt{numgen}, \texttt{hash}, or \texttt{symhash} expressions, and a map statement can be used to select fixed queue numbers based on inputs such as source IP addresses or interface names.
|
||||
|
||||
Two flags are defined for the \texttt{queue} statement: \texttt{bypass} and \texttt{fanout}.
|
||||
The \texttt{fanout} flag distributes packets between several queues.
|
||||
The \texttt{bypass} flag allows packets to proceed when the userspace application cannot process them; the documentation recommends consulting the \texttt{libnetfilter\_queue} documentation for performance-tuning recommendations before using this flag.
|
||||
```
|
||||
consulting the \texttt{libnetfilter\_queue} documentation for performance-tuning recommendations before using this flag.
|
||||
|
||||
|
||||
\section{Linux Packet Processing Path}
|
||||
\label{sec:linux-packet-processing-path}
|
||||
|
||||
The following section explains how network packets are processed by the Linux kernel. First, the internal packet representation is described. Next, the receive path from the \ac{NIC} into the kernel is outlined. Then, the local delivery, forwarding, and bridge paths are distinguished. Lastly, the relevant programmable hook points are explained because they define where a transparent traffic capture and manipulation platform can observe, mark, forward, or drop packets.
|
||||
|
||||
Linux networking is not a single processing step. Instead, packets move through device drivers, protocol implementations, routing or bridge logic, filtering hooks, queueing disciplines, and user space socket interfaces \cite{linuxkernelnetworkingdocs}. The exact path depends on whether a packet is locally generated, locally delivered, routed, or bridged \cite{stephan2024packetpath}. This distinction is important for the present thesis because a transparent \ac{MITM} system should normally forward frames at Layer 2, while still observing and manipulating packets at selected kernel hook points.
|
||||
|
||||
\subsection{Packet Representation}
|
||||
|
||||
On an Ethernet-based system, the bytes on the wire are structured as a frame. The Ethernet header contains source and destination \ac{MAC} addresses and an \texttt{EtherType} field. Depending on the \texttt{EtherType}, the frame may contain an \ac{ARP} message, an \ac{IPv4} packet, an \ac{IPv6} packet, or another payload. For \ac{IP} traffic, the network-layer header is followed by a transport-layer header such as \ac{TCP} or \ac{UDP}. The remaining bytes form the payload delivered to the application or forwarded to another interface.
|
||||
|
||||
Inside the Linux kernel, packets are mainly represented by \texttt{struct sk\_buff} \cite{linuxkernelskbuffdocs}. This structure does not contain the packet bytes directly. Instead, it stores metadata and pointers to one or more buffers that contain the actual headers and payload \cite{linuxkernelskbuffdocs}. The \texttt{head}, \texttt{data}, \texttt{tail}, and \texttt{end} pointers describe the usable packet buffer, while header offsets such as \texttt{mac\_header}, \texttt{network\_header}, and \texttt{transport\_header} indicate where individual protocol headers begin \cite{linuxkernelskbuffdocs}. As a result, protocol layers can prepend or remove headers by adjusting pointers instead of copying the complete packet \cite{stephan2024packetpath}.
|
||||
|
||||
The \texttt{sk\_buff} also carries processing metadata such as the receiving or transmitting network device, the packet length, protocol information, checksum state, priority values, and marks \cite{linuxkernelskbuffdocs}. Such metadata is not visible on the wire, but it can influence routing, filtering, queueing, and later processing stages. This property is useful for packet correlation because a mark stored in \texttt{skb->mark} can follow a packet through multiple kernel stages without changing the actual Ethernet frame.
|
||||
|
||||
Furthermore, Linux can clone an \texttt{sk\_buff} efficiently. A clone gets its own metadata structure while sharing the packet data buffer until modification becomes necessary. This is relevant for packet capture. Passive observers such as raw packet sockets can receive a clone of the packet while the original packet continues through the normal kernel path. Hence, capturing a packet does not necessarily mean that the packet was consumed by the capture process \cite{linuxkernelskbuffdocs}.
|
||||
|
||||
\subsection{Ingress Path}
|
||||
|
||||
The ingress path begins when the \ac{NIC} receives a frame from the physical medium. Modern \acp{NIC} often use multiple receive queues. With \ac{RSS}, the device can assign packets to queues based on a hash over packet header fields, allowing receive processing to be distributed over multiple \acp{CPU} \cite{linuxkernelscalingdocs}. The received bytes are transferred into main memory using \ac{DMA}, and the driver notifies the kernel that new receive work is available. Drivers commonly process this work through \ac{NAPI}, which combines interrupt notification with polling under load.
|
||||
|
||||
Before the regular networking stack processes the packet, \ac{XDP} may run in supported drivers \cite{hoilandjorgensen2018xdp}. Native \ac{XDP} executes an \ac{eBPF} program very early in the receive path, before the kernel allocates the normal \texttt{sk\_buff} structure \cite{hoilandjorgensen2018xdp}. The program can return a verdict to pass the packet to the kernel stack, drop it, transmit it back out, or redirect it to another target \cite{hoilandjorgensen2018xdp}. This makes \ac{XDP} useful for high-performance packet processing \cite{scholz2018ebpfpacketfiltering}. However, the early position also means that normal \texttt{sk\_buff} metadata is not yet available in native mode.
|
||||
|
||||
If the packet continues into the regular networking stack, the driver creates or completes an \texttt{sk\_buff} and passes it into the generic receive path, commonly through functions such as \texttt{netif\_receive\_skb()} \cite{stephan2024packetpath}. At this stage, Linux has metadata about the receiving interface and can expose the packet to early ingress processing. This includes \texttt{tc} ingress programs and the \texttt{nftables} \texttt{netdev} \texttt{ingress} hook \cite{nftableshooks}. In contrast to native \ac{XDP}, these hooks operate after the \texttt{sk\_buff} exists and can therefore read or write metadata such as \texttt{skb->mark}.
|
||||
|
||||
After early ingress processing, the packet may be cloned for packet sockets, handled by \ac{VLAN} logic, passed to a receive handler associated with a master device, or delivered to a protocol handler \cite{stephan2024packetpath}. The receive handler is particularly relevant for Linux bridges. If the ingress interface is enslaved to a bridge, the bridge receive handler can take ownership of the packet before the packet is delivered to the local \ac{IP} stack \cite{linuxkernelbridgedocs}.
|
||||
|
||||
\subsection{Local Delivery and \ac{IP} Forwarding}
|
||||
|
||||
If the packet is an \ac{IP} packet and is not taken over by a bridge or another master device, the \ac{IP} receive function processes it. For \ac{IPv4}, this path includes \texttt{ip\_rcv()}. The kernel validates essential header fields, checks packet length and checksum information, sets the transport header pointer, and invokes the \texttt{netfilter} \texttt{PRE\_ROUTING} hook. Afterwards, the routing decision determines whether the packet is locally delivered, forwarded to another interface, or handled as multicast traffic \cite{stephan2024packetpath}.
|
||||
|
||||
For local delivery, the packet follows the input path. Fragmented packets may first be reassembled. The packet then reaches the \texttt{netfilter} \texttt{LOCAL\_IN} hook and is passed to the appropriate transport-layer handler. For \ac{TCP}, Linux performs socket lookup, checksum validation, state-machine processing, sequence-number handling, and receive-queue insertion. For \ac{UDP}, the path is shorter and mainly consists of checksum validation, socket lookup, and datagram delivery. Finally, a user-space application reads the data through a system call such as \texttt{recv()} or \texttt{read()} \cite{stephan2024packetpath}.
|
||||
|
||||
For routed forwarding, the packet follows a different path. After the routing decision, \texttt{netfilter} can inspect the packet at the \texttt{FORWARD} hook. If the packet is accepted, Linux applies post-routing processing, performs neighbor resolution if necessary, and sends the packet to the selected output device. The kernel documentation on \texttt{netfilter} \texttt{flowtable} processing describes this classic forwarding path as a sequence of ingress, prerouting, routing decision, forward, postrouting, and neighbor transmission, while also describing how \texttt{flowtable} offload can bypass parts of that path for later packets of a flow \cite{linuxkernelflowtabledocs}.
|
||||
|
||||
\subsection{Ethernet Switching Concepts}
|
||||
|
||||
Ethernet switching is based on forwarding at the data link layer. The \ac{IEEE} \texttt{802.1Q-2022} standard specifies the operation of \ac{MAC} bridges and \ac{VLAN} bridges, which interconnect \acp{LAN} below the \ac{MAC} service boundary \cite{ieee8021q2022}. From the perspective of higher-layer protocols, such a bridge should be transparent: endpoints do not need to know that an intermediate bridge forwards the frame. Consequently, forwarding decisions are based on Ethernet destination addresses and bridge state rather than on \ac{IP} routes.
|
||||
|
||||
A learning bridge builds forwarding state from the source address of received frames. When a frame enters a bridge port, the bridge can associate the source \ac{MAC} address with the ingress port and store this association in the \ac{FDB} \cite{linuxkernelbridgedocs}. In \ac{VLAN}-aware operation, the relevant forwarding identity also includes the \ac{VLAN}; the Linux switch device documentation describes a bridge \ac{FDB} entry as a \texttt{\{port, mac, vlan\}} forwarding destination \cite{linuxkernelswitchdevdocs}. This distinction matters because the same \ac{MAC} address can belong to different Layer-2 domains when \acp{VLAN} are used.
|
||||
|
||||
If the destination address is known, the bridge can forward a unicast frame only to the port associated with that destination. If the destination is located on the same port as the source, the frame can be filtered instead of being sent back to the segment from which it arrived. If no matching destination entry exists, the frame is an unknown unicast and must be flooded to eligible ports in the same forwarding domain. Broadcast frames are also flooded within that domain, and multicast frames are flooded or forwarded according to multicast bridge state \cite{linuxkernelswitchdevdocs}. Thus, a bridge extends a broadcast domain unless \ac{VLAN} filtering or another separation mechanism divides the traffic into distinct Layer-2 domains.
|
||||
|
||||
\ac{VLAN} awareness allows one physical or virtual bridge to represent multiple separated broadcast domains. With \texttt{vlan\_filtering} enabled, forwarding decisions depend on both the destination \ac{MAC} address and the \ac{VLAN} tag \cite{linuxkernelbridgedocs}. The \texttt{ip-link(8)} manual describes the same configuration point as \texttt{vlan\_filtering}; when it is disabled, the bridge does not consider the \ac{VLAN} tag during packet handling \cite{man7iplink}.
|
||||
|
||||
Layer-2 loops are especially problematic because Ethernet frames do not contain a hop limit comparable to the \ac{IP} \ac{TTL} field. In a looped topology, flooded broadcast, multicast, or unknown-unicast frames can therefore circulate and be replicated until the network becomes unusable. \ac{STP} was introduced to let bridges compute a loop-free active topology in an extended \ac{LAN} \cite{perlman1985spanningtree}. \ac{RSTP} later improved reconfiguration behavior and is part of the modern bridge standards lineage described by \texttt{802.1Q} \cite{ieee8021q2022}. In Linux, \ac{STP} controls bridge port states such as blocking, learning, and forwarding, and it uses \acp{BPDU} to exchange topology information \cite{linuxkernelbridgedocs}.
|
||||
|
||||
\subsection{Linux Bridge Forwarding Path}
|
||||
|
||||
A Linux bridge implements the switching behavior described above inside the kernel. The bridge receives Ethernet frames from enslaved interfaces, learns source addresses, consults the \ac{FDB}, and either forwards, filters, floods, or locally delivers frames depending on the destination address and bridge configuration \cite{linuxkernelbridgedocs}. The \texttt{bridge} command exposes this state through objects such as \texttt{fdb}, \texttt{vlan}, and \texttt{link} \cite{man7bridge}.
|
||||
|
||||
This Layer-2 behavior is central for transparent interception. When two hosts communicate through a Linux bridge, their packets do not need to be routed by the bridge. Therefore, no additional \ac{IP} hop is introduced and the \ac{TTL} or hop-limit value is not decremented by normal bridge forwarding. From the perspective of the endpoints, the bridge behaves like an Ethernet segment or switch, although the kernel can still inspect, mark, filter, and capture frames while they traverse the bridge.
|
||||
|
||||
The bridge path has its own \texttt{netfilter} integration \cite{nftablesbridgefiltering}. The \texttt{nftables} \texttt{bridge} family provides hook points before and after the \ac{FDB} decision \cite{nftablesbridgefiltering}. In the \texttt{prerouting} hook, packets can be filtered before the bridge decides the output port. In the \texttt{forward} hook, packets can be filtered when they are bridged from one port to another. The \texttt{input} hook covers frames passed to the local stack, \texttt{output} covers frames coming from the local stack toward a bridge port, and \texttt{postrouting} covers both locally generated and forwarded bridge traffic \cite{nftablesbridgefiltering}.
|
||||
|
||||
The distinction between the \texttt{inet}, \texttt{ip}, and \texttt{bridge} \texttt{nftables} families is important. Rules in the \texttt{ip} or \texttt{inet} family operate on packets that enter the \ac{IP} stack. Rules in the \texttt{bridge} family operate on Ethernet frames in the bridge path. A transparent bridge that should inspect traffic without acting as an \ac{IP} router therefore needs \texttt{bridge}-family rules for Layer-2 forwarding decisions.
|
||||
|
||||
For the setup used in the present thesis, \ac{STP} is not required because the bridge is used as a controlled inline bridge between two network segments and no redundant Layer-2 path is intentionally introduced. Disabling \ac{STP} through \texttt{stp\_state} avoids topology negotiation, \ac{BPDU} processing, and forwarding-delay behavior that would otherwise add configuration-dependent effects to packet timing \cite{man7iplink}. This is only safe under the assumption that the physical and virtual topology is loop-free. If additional bridge ports or redundant links are added, \ac{STP} or \ac{RSTP} should remain enabled because Linux uses it to prevent loops and broadcast storms in Ethernet networks \cite{linuxkernelbridgedocs}.
|
||||
|
||||
\subsection{Egress Path}
|
||||
|
||||
The egress path depends on where the packet originates. For locally generated traffic, the path begins when an application writes to a socket. The socket layer calls functions such as \texttt{sock\_sendmsg()}, which select the transport-layer implementation. At this point, \acp{LSM} may already apply security checks. The \ac{TCP} implementation segments data, maintains connection state, enforces congestion-control behavior, and enqueues \texttt{sk\_buff} structures in the socket write queue. The \ac{UDP} implementation builds datagrams with less connection state and less protocol machinery \cite{stephan2024packetpath}.
|
||||
|
||||
After transport-layer processing, the packet enters the \ac{IP} output path. Linux determines the route, often by consulting the \ac{FIB}, and builds the \ac{IP} header. \texttt{Netfilter} can inspect locally generated traffic at \texttt{LOCAL\_OUT} and later at \texttt{POST\_ROUTING}. If the destination is on an Ethernet network, the neighbor subsystem resolves the next-hop \ac{MAC} address, for example through \ac{ARP}. Then the Ethernet header is prepared and the packet is passed to the device transmission path \cite{stephan2024packetpath}.
|
||||
|
||||
For both locally generated and forwarded packets, the final transmission path goes through the network device queueing layer. Linux calls \texttt{dev\_queue\_xmit()}, where queueing disciplines can schedule, delay, classify, or drop packets. \texttt{tc} egress programs can also run at this stage. Afterwards, the driver transmission function, commonly exposed as \texttt{ndo\_start\_xmit}, places the packet into the transmit ring of the \ac{NIC}. The packet buffer is mapped for \ac{DMA}, and the hardware transmits the frame onto the physical medium \cite{stephan2024packetpath}.
|
||||
|
||||
For bridged packets, the local socket and transport-layer construction steps are skipped. The packet already exists as an Ethernet frame. After the bridge has selected an output port and the frame has passed the relevant bridge filtering hooks, the packet enters the output device path and is eventually queued for transmission on the selected interface. Consequently, \texttt{tc} egress and device-level queueing remain relevant even for purely bridged traffic.
|
||||
|
||||
\subsection{Programmable Hook Points}
|
||||
|
||||
Linux provides several hook points that allow packet processing to be extended without modifying the kernel source code. \ac{eBPF}, the successor of \ac{BPF}, is one of the main mechanisms for this \cite{man7tcbpf}. It allows user-supplied programs to be loaded into the kernel and executed at designated hooks after verification by the kernel \cite{gbadamosi2024ebpfruntime}. The verifier is intended to ensure that programs cannot corrupt kernel memory or run without bounds, while just-in-time compilation can provide efficient execution \cite{man7tcbpf}.
|
||||
|
||||
\texttt{Netfilter} and \texttt{nftables} provide another programmable processing layer. The \texttt{nftables} hook model distinguishes packet families, hook names, chain types, and priorities. Locally delivered packets pass through \texttt{prerouting} and \texttt{input}; forwarded routed packets pass through \texttt{prerouting}, \texttt{forward}, and \texttt{postrouting}; locally generated packets pass through \texttt{output} and \texttt{postrouting}. Within a hook, priorities determine the order in which \texttt{nftables} chains and internal \texttt{netfilter} operations run \cite{nftableshooks}.
|
||||
|
||||
The earliest hook point considered here is \ac{XDP}. Native \ac{XDP} programs are executed in the driver receive path before the normal \texttt{sk\_buff} is allocated \cite{hoilandjorgensen2018xdp}. This position allows very early pass, drop, transmit, and redirect decisions \cite{hoilandjorgensen2018xdp}. Because of this position, \ac{XDP} is suitable for high packet-rate processing \cite{scholz2018ebpfpacketfiltering}. However, because the packet has not yet entered the regular \texttt{sk\_buff}-based networking stack, normal \texttt{sk\_buff} metadata is not available in native \ac{XDP} mode.
|
||||
|
||||
After an \texttt{sk\_buff} exists, \texttt{tc} ingress and egress programs can process packets as \ac{eBPF} classifiers \cite{man7tcbpf}. The ingress side is reached shortly after the packet enters the receive path, while the egress side is reached after routing or bridge forwarding has selected an output interface \cite{stephan2024packetpath}. Since these programs operate on an \texttt{\_\_sk\_buff} context, they can inspect packet bytes and use metadata such as \texttt{skb->mark} \cite{man7tcbpf}. This makes \texttt{tc}/\ac{eBPF} useful for low-overhead telemetry and packet correlation without changing the frame transmitted on the wire.
|
||||
|
||||
For bridged traffic, \texttt{nftables} \texttt{bridge} hooks provide the main verdict mechanism. Rules in the \texttt{bridge} family are evaluated in the bridge path and can therefore affect Ethernet frames that are forwarded between bridge ports without entering the routed \ac{IP} path \cite{nftablesbridgefiltering}. In particular, \texttt{bridge}-family rules can be attached before or after the \ac{FDB} decision \cite{nftablesbridgefiltering}. They can be used to accept, drop, or redirect frames at Layer 2 \cite{westphal2016bridgefiltering}.
|
||||
|
||||
For passive raw capture, Linux provides packet sockets through \texttt{AF\_PACKET}. Packet sockets are used to receive or send raw packets at the device-driver level and can be bound to a specific interface \cite{man7packet}. This makes them suitable for Layer-2 observation of Ethernet frames. In the context of a forwarding bridge, such capture is conceptually separate from the bridge forwarding decision, because observing a packet through a packet socket does not itself define the packet's forwarding verdict.
|
||||
|
||||
Lastly, \texttt{tracepoint} hooks expose selected kernel events to tracing tools and \ac{eBPF} programs \cite{linuxkerneltracepointsdocs}. They are useful for events that are difficult to infer from raw packet captures alone, for example packet free or drop paths. In such cases, \texttt{tracepoint}-based telemetry can complement ingress and egress observations by providing metadata about what happened to an \texttt{sk\_buff} inside the kernel \cite{gbadamosi2024ebpfruntime}.
|
||||
|
||||
\ac{NFQUEUE} is built on top of \texttt{netfilter}. A rule can queue a packet to user space, where an application inspects the packet and returns a verdict such as accept, drop, or modified accept. This is more flexible than a purely in-kernel rule, but it also introduces user-kernel transfer overhead and makes packet latency depend on the user-space application. Therefore, \ac{NFQUEUE} is suitable for programmable manipulation, while early in-kernel hooks are better suited for low-overhead telemetry or simple filtering.
|
||||
|
||||
For the present thesis, these hook points explain the structure of the developed system. Raw packet capture observes frame contents, \texttt{tc}/\ac{eBPF} telemetry observes kernel metadata on ingress and egress, \texttt{nftables} \texttt{bridge} rules can decide the fate of bridged packets, and packet marks can connect observations from different stages of the same kernel path. Since a single Ethernet frame can be captured, cloned, forwarded, marked, and later observed again on another interface, reliable correlation requires an explicit packet identity or a stable reconstruction from packet fields.
|
||||
74
documentation/thesis/appendix.tex
Normal file
@@ -0,0 +1,74 @@
|
||||
%-----------------------------------------------------------------------
|
||||
\chapter{Appendix}
|
||||
\label{ch:appendix}
|
||||
%-----------------------------------------------------------------------
|
||||
The following listing shows example data accessible via the \ac{HTTP} request shown in Listing~\ref{lst:userid-request}.
|
||||
This data is can be gathered by an adversary through an \ac{URL}-manipulation attack on the \texttt{userID} parameter.\\[0.2cm]
|
||||
\begin{lstlisting}[language=json, caption={Example data gathered through the URL-manipulation attack on the Victure VD300 backend using the request shown in Listing~\ref{lst:userid-request}.}, label={lst:userid_url}]
|
||||
{
|
||||
"light": [],
|
||||
"doorbell": [
|
||||
{
|
||||
"nvrID": 0,
|
||||
"devStatus": 1,
|
||||
"updateVersion": false,
|
||||
"bellVoice": "",
|
||||
"iotType": 1,
|
||||
"deviceImg": null,
|
||||
"deviceName": <redacted>,
|
||||
"userID": <redacted>,
|
||||
"closePush": 0,
|
||||
"devUid": "",
|
||||
"nvrNum": "",
|
||||
"cloudType": 1,
|
||||
"deviceP2P": "ppcs",
|
||||
"isBindingTY": "D",
|
||||
"radius": "-1",
|
||||
"relayLicenseID": "",
|
||||
"deviceUUID": <UUID redacted>,
|
||||
"longitude": "200",
|
||||
"hasAlertMsg": true,
|
||||
"deviceTypeName": "https://meari-eu.oss-eu-central-1.aliyuncs.com/deviceInfo/bell7s.png",
|
||||
"timeZone": "UTC01:00",
|
||||
"snNum": <serial/license number redacted>,
|
||||
"updatePersion": "N",
|
||||
"devTypeID": 4,
|
||||
"cloudSupport": 0,
|
||||
"userAccount": "<email-address redacted>,
|
||||
"voicemail": <URLs to voice recordings redacted>
|
||||
"trialCloud": 0,
|
||||
"region": "Europe/Berlin",
|
||||
"nvrKey": "",
|
||||
"userFlag": "Y",
|
||||
"latitude": "200",
|
||||
"p2pInit": "<redacted>",
|
||||
"deviceVersionID": "ppstrong-b5-apeman-3.1.2.20200324",
|
||||
"sleep": "off",
|
||||
"capability": "{\"ver\":21,\"cat\":\"bell\",\"caps\":{\"pdt\":13,\"dnm\":1,\"cs2\":113,\"alp\":1,\"cst\":1,\"pwm\":1,\"rng\":3,\"vtk\":4,\"nst\":1,\"hms\":2,\"sd\":1,\"spp\":1,\"cse\":1,\"ecs\":0,\"cct\":0,\"esd\":50,\"pir\":4,\"btl\":0,\"ota\":1,\"ovc\":1,\"wkp\":1}}",
|
||||
"firmID": 8,
|
||||
"nvrUUID": "",
|
||||
"wifiName": "",
|
||||
"cloudstatus": 4,
|
||||
"asFriend": false,
|
||||
"protocolVersion": 4,
|
||||
"timeZone2": "CET01:00:00CEST02:00:00,M3.5.0,M10.5.0",
|
||||
"awsThingName": "",
|
||||
"awsCloudCompat": 1,
|
||||
"shareAccessSign": 0,
|
||||
"hostKey": "<redacted>",
|
||||
"hostKey1": "",
|
||||
"deviceID": <redacted>,
|
||||
"tp": "<redacted>",
|
||||
"nvrPort": -1,
|
||||
"p2pInitApp": "<redacted>:WeEye2ppStronGer"
|
||||
}
|
||||
],
|
||||
"resultCode": "1001",
|
||||
"nvr": [],
|
||||
"fourthGeneration": [],
|
||||
"ipc": [],
|
||||
"snap": [],
|
||||
"voiceBell": [],
|
||||
"chime": []
|
||||
}
|
||||
\end{lstlisting}
|
||||
326
documentation/thesis/ba.bib
Normal file
@@ -0,0 +1,326 @@
|
||||
@article{cerf1974protocol,
|
||||
author = {Cerf, Vinton G. and Kahn, Robert E.},
|
||||
title = {A Protocol for Packet Network Intercommunication},
|
||||
journaltitle = {IEEE Transactions on Communications},
|
||||
volume = {22},
|
||||
number = {5},
|
||||
pages = {637--648},
|
||||
date = {1974-05},
|
||||
doi = {10.1109/TCOM.1974.1092259}
|
||||
}
|
||||
|
||||
@techreport{rfc791,
|
||||
author = {Postel, Jon},
|
||||
title = {Internet Protocol},
|
||||
type = {RFC},
|
||||
number = {791},
|
||||
institution = {RFC Editor},
|
||||
date = {1981-09},
|
||||
doi = {10.17487/RFC0791}
|
||||
}
|
||||
|
||||
@techreport{rfc793,
|
||||
author = {Postel, Jon},
|
||||
title = {Transmission Control Protocol},
|
||||
type = {RFC},
|
||||
number = {793},
|
||||
institution = {RFC Editor},
|
||||
date = {1981-09},
|
||||
doi = {10.17487/RFC0793}
|
||||
}
|
||||
|
||||
@techreport{rfc1122,
|
||||
author = {Braden, Robert},
|
||||
title = {Requirements for Internet Hosts -- Communication Layers},
|
||||
type = {RFC},
|
||||
number = {1122},
|
||||
institution = {RFC Editor},
|
||||
date = {1989-10},
|
||||
doi = {10.17487/RFC1122}
|
||||
}
|
||||
|
||||
@techreport{rfc1812,
|
||||
author = {Baker, Fred},
|
||||
title = {Requirements for {IP} Version 4 Routers},
|
||||
type = {RFC},
|
||||
number = {1812},
|
||||
institution = {RFC Editor},
|
||||
date = {1995-06},
|
||||
doi = {10.17487/RFC1812}
|
||||
}
|
||||
|
||||
@techreport{rfc3022,
|
||||
author = {Srisuresh, Pyda and Egevang, Kjeld},
|
||||
title = {Traditional {IP} Network Address Translator ({Traditional NAT})},
|
||||
type = {RFC},
|
||||
number = {3022},
|
||||
institution = {RFC Editor},
|
||||
date = {2001-01},
|
||||
doi = {10.17487/RFC3022}
|
||||
}
|
||||
|
||||
@inproceedings{stephan2024packetpath,
|
||||
author = {Stephan, Alexander and W{\"u}strich, Lars},
|
||||
title = {The Path of a Packet Through the Linux Kernel},
|
||||
booktitle = {Seminar IITM WS 23},
|
||||
date = {2024},
|
||||
doi = {10.2313/NET-2024-04-1\_16},
|
||||
url = {https://www.net.in.tum.de/fileadmin/TUM/NET/NET-2024-04-1/NET-2024-04-1_16.pdf}
|
||||
}
|
||||
|
||||
@inproceedings{hoilandjorgensen2018xdp,
|
||||
author = {H{\o}iland-J{\o}rgensen, Toke and Brouer, Jesper Dangaard and Borkmann, Daniel and Fastabend, John and Herbert, Tom and Ahern, David and Miller, David},
|
||||
title = {The {eXpress} Data Path: Fast Programmable Packet Processing in the Operating System Kernel},
|
||||
booktitle = {Proceedings of the 14th International Conference on Emerging Networking Experiments and Technologies},
|
||||
series = {CoNEXT '18},
|
||||
pages = {54--66},
|
||||
publisher = {Association for Computing Machinery},
|
||||
location = {Heraklion, Greece},
|
||||
date = {2018},
|
||||
doi = {10.1145/3281411.3281443},
|
||||
url = {https://doi.org/10.1145/3281411.3281443}
|
||||
}
|
||||
|
||||
@inproceedings{scholz2018ebpfpacketfiltering,
|
||||
author = {Scholz, Dominik and Raumer, Daniel and Emmerich, Paul and Kurtz, Alexander and Lesiak, Krzysztof and Carle, Georg},
|
||||
title = {Performance Implications of Packet Filtering with {Linux eBPF}},
|
||||
booktitle = {2018 30th International Teletraffic Congress},
|
||||
series = {ITC 30},
|
||||
pages = {209--217},
|
||||
publisher = {IEEE},
|
||||
location = {Vienna, Austria},
|
||||
date = {2018},
|
||||
doi = {10.1109/ITC30.2018.00039},
|
||||
url = {https://www.net.in.tum.de/fileadmin/bibtex/publications/papers/ITC30-Packet-Filtering-eBPF-XDP.pdf}
|
||||
}
|
||||
|
||||
@online{gbadamosi2024ebpfruntime,
|
||||
author = {Gbadamosi, Bolaji and Leonardi, Luigi and Pulls, Tobias and H{\o}iland-J{\o}rgensen, Toke and Ferlin-Reiter, Simone and Sorce, Simo and Brunstr{\"o}m, Anna},
|
||||
title = {The {eBPF} Runtime in the {Linux} Kernel},
|
||||
date = {2024-10-03},
|
||||
eprint = {2410.00026},
|
||||
eprinttype = {arXiv},
|
||||
doi = {10.48550/arXiv.2410.00026},
|
||||
url = {https://arxiv.org/abs/2410.00026},
|
||||
urldate = {2026-05-15}
|
||||
}
|
||||
|
||||
@inproceedings{westphal2016bridgefiltering,
|
||||
author = {Westphal, Florian},
|
||||
title = {Bridge Filtering with {nftables}},
|
||||
booktitle = {Proceedings of Netdev 1.1},
|
||||
location = {Seville, Spain},
|
||||
date = {2016},
|
||||
url = {https://netdevconf.org/1.1/proceedings/papers/Bridge-filter-with-nftables.pdf},
|
||||
urldate = {2026-05-15}
|
||||
}
|
||||
|
||||
@article{conti2016mitmsurvey,
|
||||
author = {Conti, Mauro and Dragoni, Nicola and Lesyk, Viktor},
|
||||
title = {A Survey of {Man In The Middle} Attacks},
|
||||
journaltitle = {IEEE Communications Surveys \& Tutorials},
|
||||
volume = {18},
|
||||
number = {3},
|
||||
pages = {2027--2051},
|
||||
date = {2016},
|
||||
doi = {10.1109/COMST.2016.2548426},
|
||||
url = {https://doi.org/10.1109/COMST.2016.2548426}
|
||||
}
|
||||
|
||||
@article{nam2012arpmitm,
|
||||
author = {Nam, Seung Yeob and Jurayev, Sirojiddin and Kim, Seung-Sik and Choi, Kwonhue and Choi, Gyu Sang},
|
||||
title = {Mitigating {ARP} Poisoning-Based {Man-in-the-Middle} Attacks in Wired or Wireless {LAN}},
|
||||
journaltitle = {EURASIP Journal on Wireless Communications and Networking},
|
||||
volume = {2012},
|
||||
number = {1},
|
||||
eid = {89},
|
||||
date = {2012},
|
||||
doi = {10.1186/1687-1499-2012-89},
|
||||
url = {https://doi.org/10.1186/1687-1499-2012-89}
|
||||
}
|
||||
|
||||
@inproceedings{zhang2007portmirroring,
|
||||
author = {Zhang, Jian and Moore, Andrew W.},
|
||||
title = {Traffic Trace Artifacts due to Monitoring Via Port Mirroring},
|
||||
booktitle = {2007 Workshop on End-to-End Monitoring Techniques and Services},
|
||||
series = {E2EMON '07},
|
||||
pages = {1--8},
|
||||
publisher = {IEEE},
|
||||
date = {2007},
|
||||
doi = {10.1109/E2EMON.2007.375317},
|
||||
url = {https://www.cl.cam.ac.uk/research/srg/netos/papers/2007-zhang2007traffic.pdf},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@inproceedings{durumeric2017httpsinterception,
|
||||
author = {Durumeric, Zakir and Ma, Zane and Springall, Drew and Barnes, Richard and Sullivan, Nick and Bursztein, Elie and Bailey, Michael and Halderman, J. Alex and Paxson, Vern},
|
||||
title = {The Security Impact of {HTTPS} Interception},
|
||||
booktitle = {Proceedings of the Network and Distributed System Security Symposium},
|
||||
series = {NDSS '17},
|
||||
date = {2017},
|
||||
doi = {10.14722/ndss.2017.23456},
|
||||
url = {https://doi.org/10.14722/ndss.2017.23456}
|
||||
}
|
||||
|
||||
@inproceedings{waked2018tlsinterception,
|
||||
author = {Waked, Louis and Mannan, Mohammad and Youssef, Amr},
|
||||
title = {To Intercept or Not to Intercept: Analyzing {TLS} Interception in Network Appliances},
|
||||
booktitle = {Proceedings of the 2018 {ACM Asia} Conference on Computer and Communications Security},
|
||||
series = {ASIACCS '18},
|
||||
pages = {399--412},
|
||||
publisher = {Association for Computing Machinery},
|
||||
location = {Incheon, Republic of Korea},
|
||||
date = {2018},
|
||||
doi = {10.1145/3196494.3196528},
|
||||
url = {https://doi.org/10.1145/3196494.3196528}
|
||||
}
|
||||
|
||||
@article{decarnedecarnavalet2023tlsinterception,
|
||||
author = {de Carn{\'e} de Carnavalet, Xavier and van Oorschot, Paul C.},
|
||||
title = {A Survey and Analysis of {TLS} Interception Mechanisms and Motivations},
|
||||
journaltitle = {ACM Computing Surveys},
|
||||
volume = {55},
|
||||
number = {13s},
|
||||
articleno = {269},
|
||||
pages = {1--40},
|
||||
date = {2023},
|
||||
doi = {10.1145/3580522},
|
||||
url = {https://doi.org/10.1145/3580522}
|
||||
}
|
||||
|
||||
@manual{ieee8021q2022,
|
||||
author = {{IEEE}},
|
||||
title = {{IEEE Standard for Local and Metropolitan Area Networks--Bridges and Bridged Networks}},
|
||||
organization = {IEEE},
|
||||
type = {IEEE Std 802.1Q-2022},
|
||||
date = {2022-12-22},
|
||||
url = {https://standards.ieee.org/ieee/802.1Q/10323/},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@inproceedings{perlman1985spanningtree,
|
||||
author = {Perlman, Radia},
|
||||
title = {An Algorithm for Distributed Computation of a Spanningtree in an Extended {LAN}},
|
||||
booktitle = {Proceedings of the Ninth Symposium on Data Communications},
|
||||
series = {SIGCOMM '85},
|
||||
pages = {44--53},
|
||||
publisher = {Association for Computing Machinery},
|
||||
location = {Whistler Mountain, British Columbia, Canada},
|
||||
date = {1985},
|
||||
doi = {10.1145/319056.319004},
|
||||
url = {https://doi.org/10.1145/319056.319004}
|
||||
}
|
||||
|
||||
@online{linuxkernelnetworkingdocs,
|
||||
author = {{The Linux Kernel Documentation Authors}},
|
||||
title = {Networking --- The Linux Kernel documentation},
|
||||
year = {2026},
|
||||
url = {https://docs.kernel.org/networking/index.html},
|
||||
urldate = {2026-04-18}
|
||||
}
|
||||
|
||||
@online{linuxkernelskbuffdocs,
|
||||
author = {{The Linux Kernel Documentation Authors}},
|
||||
title = {struct sk\_buff --- The Linux Kernel documentation},
|
||||
year = {2026},
|
||||
url = {https://docs.kernel.org/networking/skbuff.html},
|
||||
urldate = {2026-04-18}
|
||||
}
|
||||
|
||||
@online{linuxkernelbridgedocs,
|
||||
author = {{The Linux Kernel Documentation Authors}},
|
||||
title = {Ethernet Bridging --- The Linux Kernel documentation},
|
||||
year = {2026},
|
||||
url = {https://docs.kernel.org/networking/bridge.html},
|
||||
urldate = {2026-04-18}
|
||||
}
|
||||
|
||||
@online{linuxkernelswitchdevdocs,
|
||||
author = {{The Linux Kernel Documentation Authors}},
|
||||
title = {Ethernet switch device driver model (switchdev) --- The Linux Kernel documentation},
|
||||
year = {2026},
|
||||
url = {https://www.kernel.org/doc/html/latest/networking/switchdev.html},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@online{linuxkernelflowtabledocs,
|
||||
author = {{The Linux Kernel Documentation Authors}},
|
||||
title = {Netfilter's Flowtable Infrastructure --- The Linux Kernel documentation},
|
||||
year = {2026},
|
||||
url = {https://docs.kernel.org/networking/nf_flowtable.html},
|
||||
urldate = {2026-05-15}
|
||||
}
|
||||
|
||||
@online{linuxkernelscalingdocs,
|
||||
author = {{The Linux Kernel Documentation Authors}},
|
||||
title = {Scaling in the Linux Networking Stack --- The Linux Kernel documentation},
|
||||
year = {2026},
|
||||
url = {https://docs.kernel.org/networking/scaling.html},
|
||||
urldate = {2026-05-15}
|
||||
}
|
||||
|
||||
@online{linuxkerneltracepointsdocs,
|
||||
author = {{The Linux Kernel Documentation Authors}},
|
||||
title = {Using the Linux Kernel Tracepoints --- The Linux Kernel documentation},
|
||||
year = {2026},
|
||||
url = {https://www.kernel.org/doc/html/latest/trace/tracepoints.html},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@online{man7packet,
|
||||
author = {{Linux man-pages project}},
|
||||
title = {packet(7) --- Linux manual page},
|
||||
date = {2025-09-21},
|
||||
url = {https://man7.org/linux/man-pages/man7/packet.7.html},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@online{man7tcbpf,
|
||||
author = {{Linux man-pages project}},
|
||||
title = {tc-bpf(8) --- Linux manual page},
|
||||
date = {2025-08-08},
|
||||
url = {https://man7.org/linux/man-pages/man8/tc-bpf.8.html},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@online{man7bridge,
|
||||
author = {{Linux man-pages project}},
|
||||
title = {bridge(8) --- Linux manual page},
|
||||
date = {2012-08-01},
|
||||
url = {https://man7.org/linux/man-pages/man8/bridge.8.html},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@online{man7iplink,
|
||||
author = {{Linux man-pages project}},
|
||||
title = {ip-link(8) --- Linux manual page},
|
||||
date = {2012-12-13},
|
||||
url = {https://man7.org/linux/man-pages/man8/ip-link.8.html},
|
||||
urldate = {2026-05-16}
|
||||
}
|
||||
|
||||
@online{nftableshooks,
|
||||
author = {{The nftables Project}},
|
||||
title = {Netfilter Hooks},
|
||||
year = {2023},
|
||||
url = {https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks},
|
||||
urldate = {2026-05-15}
|
||||
}
|
||||
|
||||
@online{nftablesbridgefiltering,
|
||||
author = {{The nftables Project}},
|
||||
title = {Bridge Filtering},
|
||||
year = {2021},
|
||||
url = {https://wiki.nftables.org/wiki-nftables/index.php/Bridge_filtering},
|
||||
urldate = {2026-05-15}
|
||||
}
|
||||
|
||||
@online{nftables_manpage,
|
||||
title = {nft(8) -- Administration Tool of the nftables Framework
|
||||
for Packet Filtering and Classification},
|
||||
author = {{The Netfilter Project}},
|
||||
organization = {The Netfilter Project},
|
||||
year = {2026},
|
||||
url = {https://netfilter.org/projects/nftables/manpage.html},
|
||||
note = {Accessed: 2026-08-08}
|
||||
}
|
||||
BIN
documentation/thesis/figures/benchmark/added_one_way_delay.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
1143
documentation/thesis/figures/benchmark/added_one_way_delay.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,91 @@
|
||||
setup,category,metric,unit,direction,payload_size_bytes,protocol,count,mean,median,min,max,std
|
||||
bridge-new,flent,data_file_exists,bool,,,,3,1.0,1.0,1.0,1.0,0.0
|
||||
bridge-new,iperf_tcp,retransmits,count,forward,,,1,0.0,0.0,0.0,0.0,
|
||||
bridge-new,iperf_tcp,retransmits,count,reverse,,,1,0.0,0.0,0.0,0.0,
|
||||
bridge-new,iperf_tcp,throughput,Mbit/s,forward,,,1,941.2170773518191,941.2170773518191,941.2170773518191,941.2170773518191,
|
||||
bridge-new,iperf_tcp,throughput,Mbit/s,reverse,,,1,941.223044856063,941.223044856063,941.223044856063,941.223044856063,
|
||||
bridge-new,iperf_udp,jitter,ms,forward,,,1,329.1133542566476,329.1133542566476,329.1133542566476,329.1133542566476,
|
||||
bridge-new,iperf_udp,jitter,ms,reverse,,,1,0.011945675546752457,0.011945675546752457,0.011945675546752457,0.011945675546752457,
|
||||
bridge-new,iperf_udp,loss,percent,forward,,,1,0.00552541229203311,0.00552541229203311,0.00552541229203311,0.00552541229203311,
|
||||
bridge-new,iperf_udp,loss,percent,reverse,,,1,0.0,0.0,0.0,0.0,
|
||||
bridge-new,iperf_udp,throughput,Mbit/s,forward,,,1,691.7975032635362,691.7975032635362,691.7975032635362,691.7975032635362,
|
||||
bridge-new,iperf_udp,throughput,Mbit/s,reverse,,,1,899.980891114048,899.980891114048,899.980891114048,899.980891114048,
|
||||
bridge-new,ping,jitter_mean_abs_delta,ms,,56.0,,1,0.25014242848569707,0.25014242848569707,0.25014242848569707,0.25014242848569707,
|
||||
bridge-new,ping,jitter_mean_abs_delta,ms,,512.0,,1,0.20838367673534708,0.20838367673534708,0.20838367673534708,0.20838367673534708,
|
||||
bridge-new,ping,jitter_mean_abs_delta,ms,,1472.0,,1,0.18928945789157833,0.18928945789157833,0.18928945789157833,0.18928945789157833,
|
||||
bridge-new,ping,loss,percent,,56.0,,1,0.0,0.0,0.0,0.0,
|
||||
bridge-new,ping,loss,percent,,512.0,,1,0.0,0.0,0.0,0.0,
|
||||
bridge-new,ping,loss,percent,,1472.0,,1,0.0,0.0,0.0,0.0,
|
||||
bridge-new,ping,rtt_iqr,ms,,56.0,,1,0.20999999999999974,0.20999999999999974,0.20999999999999974,0.20999999999999974,
|
||||
bridge-new,ping,rtt_iqr,ms,,512.0,,1,0.17000000000000015,0.17000000000000015,0.17000000000000015,0.17000000000000015,
|
||||
bridge-new,ping,rtt_iqr,ms,,1472.0,,1,0.15999999999999992,0.15999999999999992,0.15999999999999992,0.15999999999999992,
|
||||
bridge-new,ping,rtt_mad,ms,,56.0,,1,0.06999999999999984,0.06999999999999984,0.06999999999999984,0.06999999999999984,
|
||||
bridge-new,ping,rtt_mad,ms,,512.0,,1,0.050000000000000266,0.050000000000000266,0.050000000000000266,0.050000000000000266,
|
||||
bridge-new,ping,rtt_mad,ms,,1472.0,,1,0.040000000000000036,0.040000000000000036,0.040000000000000036,0.040000000000000036,
|
||||
bridge-new,ping,rtt_max,ms,,56.0,,1,2.24,2.24,2.24,2.24,
|
||||
bridge-new,ping,rtt_max,ms,,512.0,,1,2.31,2.31,2.31,2.31,
|
||||
bridge-new,ping,rtt_max,ms,,1472.0,,1,2.36,2.36,2.36,2.36,
|
||||
bridge-new,ping,rtt_mean,ms,,56.0,,1,1.7996464,1.7996464,1.7996464,1.7996464,
|
||||
bridge-new,ping,rtt_mean,ms,,512.0,,1,1.866984,1.866984,1.866984,1.866984,
|
||||
bridge-new,ping,rtt_mean,ms,,1472.0,,1,1.910105,1.910105,1.910105,1.910105,
|
||||
bridge-new,ping,rtt_median,ms,,56.0,,1,1.98,1.98,1.98,1.98,
|
||||
bridge-new,ping,rtt_median,ms,,512.0,,1,2.03,2.03,2.03,2.03,
|
||||
bridge-new,ping,rtt_median,ms,,1472.0,,1,2.08,2.08,2.08,2.08,
|
||||
bridge-new,ping,rtt_min,ms,,56.0,,1,0.279,0.279,0.279,0.279,
|
||||
bridge-new,ping,rtt_min,ms,,512.0,,1,0.306,0.306,0.306,0.306,
|
||||
bridge-new,ping,rtt_min,ms,,1472.0,,1,0.373,0.373,0.373,0.373,
|
||||
bridge-new,ping,rtt_p95,ms,,56.0,,1,2.09,2.09,2.09,2.09,
|
||||
bridge-new,ping,rtt_p95,ms,,512.0,,1,2.13,2.13,2.13,2.13,
|
||||
bridge-new,ping,rtt_p95,ms,,1472.0,,1,2.18,2.18,2.18,2.18,
|
||||
bridge-new,ping,rtt_p99,ms,,56.0,,1,2.14,2.14,2.14,2.14,
|
||||
bridge-new,ping,rtt_p99,ms,,512.0,,1,2.17,2.17,2.17,2.17,
|
||||
bridge-new,ping,rtt_p99,ms,,1472.0,,1,2.21,2.21,2.21,2.21,
|
||||
bridge-new,ping,rtt_stdev,ms,,56.0,,1,0.42052572542496,0.42052572542496,0.42052572542496,0.42052572542496,
|
||||
bridge-new,ping,rtt_stdev,ms,,512.0,,1,0.38826014131180947,0.38826014131180947,0.38826014131180947,0.38826014131180947,
|
||||
bridge-new,ping,rtt_stdev,ms,,1472.0,,1,0.40225082364120024,0.40225082364120024,0.40225082364120024,0.40225082364120024,
|
||||
bridge-new,sockperf,avg_latency_usec,us,,,tcp,1,242.02,242.02,242.02,242.02,
|
||||
direct,flent,data_file_exists,bool,,,,3,1.0,1.0,1.0,1.0,0.0
|
||||
direct,iperf_tcp,retransmits,count,forward,,,1,0.0,0.0,0.0,0.0,
|
||||
direct,iperf_tcp,retransmits,count,reverse,,,1,0.0,0.0,0.0,0.0,
|
||||
direct,iperf_tcp,throughput,Mbit/s,forward,,,1,941.4052500378058,941.4052500378058,941.4052500378058,941.4052500378058,
|
||||
direct,iperf_tcp,throughput,Mbit/s,reverse,,,1,941.4233862520524,941.4233862520524,941.4233862520524,941.4233862520524,
|
||||
direct,iperf_udp,jitter,ms,forward,,,1,0.010443516671235937,0.010443516671235937,0.010443516671235937,0.010443516671235937,
|
||||
direct,iperf_udp,jitter,ms,reverse,,,1,0.010410725838564765,0.010410725838564765,0.010410725838564765,0.010410725838564765,
|
||||
direct,iperf_udp,loss,percent,forward,,,1,0.0,0.0,0.0,0.0,
|
||||
direct,iperf_udp,loss,percent,reverse,,,1,0.002895969068476154,0.002895969068476154,0.002895969068476154,0.002895969068476154,
|
||||
direct,iperf_udp,throughput,Mbit/s,forward,,,1,899.951785108759,899.951785108759,899.951785108759,899.951785108759,
|
||||
direct,iperf_udp,throughput,Mbit/s,reverse,,,1,899.961104896446,899.961104896446,899.961104896446,899.961104896446,
|
||||
direct,ping,jitter_mean_abs_delta,ms,,56.0,,1,0.16798879775955192,0.16798879775955192,0.16798879775955192,0.16798879775955192,
|
||||
direct,ping,jitter_mean_abs_delta,ms,,512.0,,1,0.18655691138227648,0.18655691138227648,0.18655691138227648,0.18655691138227648,
|
||||
direct,ping,jitter_mean_abs_delta,ms,,1472.0,,1,0.18377075415083016,0.18377075415083016,0.18377075415083016,0.18377075415083016,
|
||||
direct,ping,loss,percent,,56.0,,1,0.0,0.0,0.0,0.0,
|
||||
direct,ping,loss,percent,,512.0,,1,0.0,0.0,0.0,0.0,
|
||||
direct,ping,loss,percent,,1472.0,,1,0.0,0.0,0.0,0.0,
|
||||
direct,ping,rtt_iqr,ms,,56.0,,1,0.14000000000000012,0.14000000000000012,0.14000000000000012,0.14000000000000012,
|
||||
direct,ping,rtt_iqr,ms,,512.0,,1,0.16000000000000014,0.16000000000000014,0.16000000000000014,0.16000000000000014,
|
||||
direct,ping,rtt_iqr,ms,,1472.0,,1,0.1200000000000001,0.1200000000000001,0.1200000000000001,0.1200000000000001,
|
||||
direct,ping,rtt_mad,ms,,56.0,,1,0.040000000000000036,0.040000000000000036,0.040000000000000036,0.040000000000000036,
|
||||
direct,ping,rtt_mad,ms,,512.0,,1,0.08000000000000007,0.08000000000000007,0.08000000000000007,0.08000000000000007,
|
||||
direct,ping,rtt_mad,ms,,1472.0,,1,0.05999999999999983,0.05999999999999983,0.05999999999999983,0.05999999999999983,
|
||||
direct,ping,rtt_max,ms,,56.0,,1,1.74,1.74,1.74,1.74,
|
||||
direct,ping,rtt_max,ms,,512.0,,1,1.78,1.78,1.78,1.78,
|
||||
direct,ping,rtt_max,ms,,1472.0,,1,1.81,1.81,1.81,1.81,
|
||||
direct,ping,rtt_mean,ms,,56.0,,1,1.3608360000000002,1.3608360000000002,1.3608360000000002,1.3608360000000002,
|
||||
direct,ping,rtt_mean,ms,,512.0,,1,1.3263896000000002,1.3263896000000002,1.3263896000000002,1.3263896000000002,
|
||||
direct,ping,rtt_mean,ms,,1472.0,,1,1.335693,1.335693,1.335693,1.335693,
|
||||
direct,ping,rtt_median,ms,,56.0,,1,1.51,1.51,1.51,1.51,
|
||||
direct,ping,rtt_median,ms,,512.0,,1,1.48,1.48,1.48,1.48,
|
||||
direct,ping,rtt_median,ms,,1472.0,,1,1.43,1.43,1.43,1.43,
|
||||
direct,ping,rtt_min,ms,,56.0,,1,0.027,0.027,0.027,0.027,
|
||||
direct,ping,rtt_min,ms,,512.0,,1,0.043,0.043,0.043,0.043,
|
||||
direct,ping,rtt_min,ms,,1472.0,,1,0.077,0.077,0.077,0.077,
|
||||
direct,ping,rtt_p95,ms,,56.0,,1,1.59,1.59,1.59,1.59,
|
||||
direct,ping,rtt_p95,ms,,512.0,,1,1.6,1.6,1.6,1.6,
|
||||
direct,ping,rtt_p95,ms,,1472.0,,1,1.63,1.63,1.63,1.63,
|
||||
direct,ping,rtt_p99,ms,,56.0,,1,1.66,1.66,1.66,1.66,
|
||||
direct,ping,rtt_p99,ms,,512.0,,1,1.65,1.65,1.65,1.65,
|
||||
direct,ping,rtt_p99,ms,,1472.0,,1,1.68,1.68,1.68,1.68,
|
||||
direct,ping,rtt_stdev,ms,,56.0,,1,0.38241341543944507,0.38241341543944507,0.38241341543944507,0.38241341543944507,
|
||||
direct,ping,rtt_stdev,ms,,512.0,,1,0.41370645730808175,0.41370645730808175,0.41370645730808175,0.41370645730808175,
|
||||
direct,ping,rtt_stdev,ms,,1472.0,,1,0.36380108603059363,0.36380108603059363,0.36380108603059363,0.36380108603059363,
|
||||
direct,sockperf,avg_latency_usec,us,,,tcp,1,21.286,21.286,21.286,21.286,
|
||||
|
95
documentation/thesis/figures/benchmark/benchmark_metrics.csv
Normal file
@@ -0,0 +1,95 @@
|
||||
setup,category,metric,value,unit,direction,payload_size_bytes,protocol,test,source
|
||||
direct,flent,data_file_exists,1.0,bool,,,,rrul,measurments/20260508-221054-direct/summary.json
|
||||
direct,flent,data_file_exists,1.0,bool,,,,tcp_upload,measurments/20260508-221054-direct/summary.json
|
||||
direct,flent,data_file_exists,1.0,bool,,,,tcp_download,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_tcp,retransmits,0.0,count,forward,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_tcp,retransmits,0.0,count,reverse,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_tcp,throughput,941.4052500378058,Mbit/s,forward,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_tcp,throughput,941.4233862520524,Mbit/s,reverse,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_udp,jitter,0.010443516671235937,ms,forward,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_udp,jitter,0.010410725838564765,ms,reverse,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_udp,loss,0.0,percent,forward,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_udp,loss,0.002895969068476154,percent,reverse,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_udp,throughput,899.951785108759,Mbit/s,forward,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,iperf_udp,throughput,899.961104896446,Mbit/s,reverse,,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,jitter_mean_abs_delta,0.16798879775955192,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,jitter_mean_abs_delta,0.18655691138227648,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,jitter_mean_abs_delta,0.18377075415083016,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,loss,0.0,percent,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,loss,0.0,percent,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,loss,0.0,percent,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_iqr,0.14000000000000012,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_iqr,0.16000000000000014,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_iqr,0.1200000000000001,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_mad,0.040000000000000036,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_mad,0.08000000000000007,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_mad,0.05999999999999983,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_max,1.74,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_max,1.78,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_max,1.81,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_mean,1.3608360000000002,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_mean,1.3263896000000002,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_mean,1.335693,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_median,1.51,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_median,1.48,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_median,1.43,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_min,0.027,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_min,0.043,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_min,0.077,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_p95,1.59,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_p95,1.6,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_p95,1.63,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_p99,1.66,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_p99,1.65,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_p99,1.68,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_stdev,0.38241341543944507,ms,,56.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_stdev,0.41370645730808175,ms,,512.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,ping,rtt_stdev,0.36380108603059363,ms,,1472.0,,,measurments/20260508-221054-direct/summary.json
|
||||
direct,sockperf,avg_latency_usec,21.286,us,,,tcp,,measurments/20260508-221054-direct/summary.json
|
||||
bridge-new,flent,data_file_exists,1.0,bool,,,,rrul,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,flent,data_file_exists,1.0,bool,,,,tcp_upload,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,flent,data_file_exists,1.0,bool,,,,tcp_download,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_tcp,retransmits,0.0,count,forward,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_tcp,retransmits,0.0,count,reverse,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_tcp,throughput,941.2170773518191,Mbit/s,forward,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_tcp,throughput,941.223044856063,Mbit/s,reverse,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_udp,jitter,329.1133542566476,ms,forward,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_udp,jitter,0.011945675546752457,ms,reverse,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_udp,loss,0.00552541229203311,percent,forward,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_udp,loss,0.0,percent,reverse,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_udp,throughput,691.7975032635362,Mbit/s,forward,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,iperf_udp,throughput,899.980891114048,Mbit/s,reverse,,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,jitter_mean_abs_delta,0.25014242848569707,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,jitter_mean_abs_delta,0.20838367673534708,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,jitter_mean_abs_delta,0.18928945789157833,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,loss,0.0,percent,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,loss,0.0,percent,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,loss,0.0,percent,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_iqr,0.20999999999999974,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_iqr,0.17000000000000015,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_iqr,0.15999999999999992,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_mad,0.06999999999999984,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_mad,0.050000000000000266,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_mad,0.040000000000000036,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_max,2.24,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_max,2.31,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_max,2.36,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_mean,1.7996464,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_mean,1.866984,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_mean,1.910105,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_median,1.98,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_median,2.03,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_median,2.08,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_min,0.279,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_min,0.306,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_min,0.373,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_p95,2.09,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_p95,2.13,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_p95,2.18,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_p99,2.14,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_p99,2.17,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_p99,2.21,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_stdev,0.42052572542496,ms,,56.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_stdev,0.38826014131180947,ms,,512.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,ping,rtt_stdev,0.40225082364120024,ms,,1472.0,,,measurments/20260508-215553-bridge-new/summary.json
|
||||
bridge-new,sockperf,avg_latency_usec,242.02,us,,,tcp,,measurments/20260508-215553-bridge-new/summary.json
|
||||
|
BIN
documentation/thesis/figures/benchmark/ping_rtt_percentiles.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
1327
documentation/thesis/figures/benchmark/ping_rtt_percentiles.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
documentation/thesis/figures/benchmark/sockperf_latency.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
910
documentation/thesis/figures/benchmark/sockperf_latency.svg
Normal file
@@ -0,0 +1,910 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="510.348pt" height="297.523321pt" viewBox="0 0 510.348 297.523321" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||
<metadata>
|
||||
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<cc:Work>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:date>2026-05-10T16:24:20.591445</dc:date>
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:creator>
|
||||
<cc:Agent>
|
||||
<dc:title>Matplotlib v3.6.3, https://matplotlib.org/</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:creator>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs>
|
||||
<style type="text/css">*{stroke-linejoin: round; stroke-linecap: butt}</style>
|
||||
</defs>
|
||||
<g id="figure_1">
|
||||
<g id="patch_1">
|
||||
<path d="M 0 297.523321
|
||||
L 510.348 297.523321
|
||||
L 510.348 0
|
||||
L 0 0
|
||||
z
|
||||
" style="fill: #ffffff"/>
|
||||
</g>
|
||||
<g id="axes_1">
|
||||
<g id="patch_2">
|
||||
<path d="M 45.588 253.3425
|
||||
L 503.148 253.3425
|
||||
L 503.148 20.4945
|
||||
L 45.588 20.4945
|
||||
z
|
||||
" style="fill: #ffffff"/>
|
||||
</g>
|
||||
<g id="matplotlib.axis_1">
|
||||
<g id="xtick_1">
|
||||
<g id="text_1">
|
||||
<!-- direct -->
|
||||
<g style="fill: #262626" transform="translate(149.605476 278.333286) rotate(-25) scale(0.088 -0.088)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-64" d="M 2906 2969
|
||||
L 2906 4863
|
||||
L 3481 4863
|
||||
L 3481 0
|
||||
L 2906 0
|
||||
L 2906 525
|
||||
Q 2725 213 2448 61
|
||||
Q 2172 -91 1784 -91
|
||||
Q 1150 -91 751 415
|
||||
Q 353 922 353 1747
|
||||
Q 353 2572 751 3078
|
||||
Q 1150 3584 1784 3584
|
||||
Q 2172 3584 2448 3432
|
||||
Q 2725 3281 2906 2969
|
||||
z
|
||||
M 947 1747
|
||||
Q 947 1113 1208 752
|
||||
Q 1469 391 1925 391
|
||||
Q 2381 391 2643 752
|
||||
Q 2906 1113 2906 1747
|
||||
Q 2906 2381 2643 2742
|
||||
Q 2381 3103 1925 3103
|
||||
Q 1469 3103 1208 2742
|
||||
Q 947 2381 947 1747
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-69" d="M 603 3500
|
||||
L 1178 3500
|
||||
L 1178 0
|
||||
L 603 0
|
||||
L 603 3500
|
||||
z
|
||||
M 603 4863
|
||||
L 1178 4863
|
||||
L 1178 4134
|
||||
L 603 4134
|
||||
L 603 4863
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-72" d="M 2631 2963
|
||||
Q 2534 3019 2420 3045
|
||||
Q 2306 3072 2169 3072
|
||||
Q 1681 3072 1420 2755
|
||||
Q 1159 2438 1159 1844
|
||||
L 1159 0
|
||||
L 581 0
|
||||
L 581 3500
|
||||
L 1159 3500
|
||||
L 1159 2956
|
||||
Q 1341 3275 1631 3429
|
||||
Q 1922 3584 2338 3584
|
||||
Q 2397 3584 2469 3576
|
||||
Q 2541 3569 2628 3553
|
||||
L 2631 2963
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-65" d="M 3597 1894
|
||||
L 3597 1613
|
||||
L 953 1613
|
||||
Q 991 1019 1311 708
|
||||
Q 1631 397 2203 397
|
||||
Q 2534 397 2845 478
|
||||
Q 3156 559 3463 722
|
||||
L 3463 178
|
||||
Q 3153 47 2828 -22
|
||||
Q 2503 -91 2169 -91
|
||||
Q 1331 -91 842 396
|
||||
Q 353 884 353 1716
|
||||
Q 353 2575 817 3079
|
||||
Q 1281 3584 2069 3584
|
||||
Q 2775 3584 3186 3129
|
||||
Q 3597 2675 3597 1894
|
||||
z
|
||||
M 3022 2063
|
||||
Q 3016 2534 2758 2815
|
||||
Q 2500 3097 2075 3097
|
||||
Q 1594 3097 1305 2825
|
||||
Q 1016 2553 972 2059
|
||||
L 3022 2063
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-63" d="M 3122 3366
|
||||
L 3122 2828
|
||||
Q 2878 2963 2633 3030
|
||||
Q 2388 3097 2138 3097
|
||||
Q 1578 3097 1268 2742
|
||||
Q 959 2388 959 1747
|
||||
Q 959 1106 1268 751
|
||||
Q 1578 397 2138 397
|
||||
Q 2388 397 2633 464
|
||||
Q 2878 531 3122 666
|
||||
L 3122 134
|
||||
Q 2881 22 2623 -34
|
||||
Q 2366 -91 2075 -91
|
||||
Q 1284 -91 818 406
|
||||
Q 353 903 353 1747
|
||||
Q 353 2603 823 3093
|
||||
Q 1294 3584 2113 3584
|
||||
Q 2378 3584 2631 3529
|
||||
Q 2884 3475 3122 3366
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-74" d="M 1172 4494
|
||||
L 1172 3500
|
||||
L 2356 3500
|
||||
L 2356 3053
|
||||
L 1172 3053
|
||||
L 1172 1153
|
||||
Q 1172 725 1289 603
|
||||
Q 1406 481 1766 481
|
||||
L 2356 481
|
||||
L 2356 0
|
||||
L 1766 0
|
||||
Q 1100 0 847 248
|
||||
Q 594 497 594 1153
|
||||
L 594 3053
|
||||
L 172 3053
|
||||
L 172 3500
|
||||
L 594 3500
|
||||
L 594 4494
|
||||
L 1172 4494
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-64"/>
|
||||
<use xlink:href="#DejaVuSans-69" x="63.476562"/>
|
||||
<use xlink:href="#DejaVuSans-72" x="91.259766"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="130.123047"/>
|
||||
<use xlink:href="#DejaVuSans-63" x="191.646484"/>
|
||||
<use xlink:href="#DejaVuSans-74" x="246.626953"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_2">
|
||||
<g id="text_2">
|
||||
<!-- bridge-new -->
|
||||
<g style="fill: #262626" transform="translate(367.30762 288.664665) rotate(-25) scale(0.088 -0.088)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-62" d="M 3116 1747
|
||||
Q 3116 2381 2855 2742
|
||||
Q 2594 3103 2138 3103
|
||||
Q 1681 3103 1420 2742
|
||||
Q 1159 2381 1159 1747
|
||||
Q 1159 1113 1420 752
|
||||
Q 1681 391 2138 391
|
||||
Q 2594 391 2855 752
|
||||
Q 3116 1113 3116 1747
|
||||
z
|
||||
M 1159 2969
|
||||
Q 1341 3281 1617 3432
|
||||
Q 1894 3584 2278 3584
|
||||
Q 2916 3584 3314 3078
|
||||
Q 3713 2572 3713 1747
|
||||
Q 3713 922 3314 415
|
||||
Q 2916 -91 2278 -91
|
||||
Q 1894 -91 1617 61
|
||||
Q 1341 213 1159 525
|
||||
L 1159 0
|
||||
L 581 0
|
||||
L 581 4863
|
||||
L 1159 4863
|
||||
L 1159 2969
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-67" d="M 2906 1791
|
||||
Q 2906 2416 2648 2759
|
||||
Q 2391 3103 1925 3103
|
||||
Q 1463 3103 1205 2759
|
||||
Q 947 2416 947 1791
|
||||
Q 947 1169 1205 825
|
||||
Q 1463 481 1925 481
|
||||
Q 2391 481 2648 825
|
||||
Q 2906 1169 2906 1791
|
||||
z
|
||||
M 3481 434
|
||||
Q 3481 -459 3084 -895
|
||||
Q 2688 -1331 1869 -1331
|
||||
Q 1566 -1331 1297 -1286
|
||||
Q 1028 -1241 775 -1147
|
||||
L 775 -588
|
||||
Q 1028 -725 1275 -790
|
||||
Q 1522 -856 1778 -856
|
||||
Q 2344 -856 2625 -561
|
||||
Q 2906 -266 2906 331
|
||||
L 2906 616
|
||||
Q 2728 306 2450 153
|
||||
Q 2172 0 1784 0
|
||||
Q 1141 0 747 490
|
||||
Q 353 981 353 1791
|
||||
Q 353 2603 747 3093
|
||||
Q 1141 3584 1784 3584
|
||||
Q 2172 3584 2450 3431
|
||||
Q 2728 3278 2906 2969
|
||||
L 2906 3500
|
||||
L 3481 3500
|
||||
L 3481 434
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-2d" d="M 313 2009
|
||||
L 1997 2009
|
||||
L 1997 1497
|
||||
L 313 1497
|
||||
L 313 2009
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-6e" d="M 3513 2113
|
||||
L 3513 0
|
||||
L 2938 0
|
||||
L 2938 2094
|
||||
Q 2938 2591 2744 2837
|
||||
Q 2550 3084 2163 3084
|
||||
Q 1697 3084 1428 2787
|
||||
Q 1159 2491 1159 1978
|
||||
L 1159 0
|
||||
L 581 0
|
||||
L 581 3500
|
||||
L 1159 3500
|
||||
L 1159 2956
|
||||
Q 1366 3272 1645 3428
|
||||
Q 1925 3584 2291 3584
|
||||
Q 2894 3584 3203 3211
|
||||
Q 3513 2838 3513 2113
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-77" d="M 269 3500
|
||||
L 844 3500
|
||||
L 1563 769
|
||||
L 2278 3500
|
||||
L 2956 3500
|
||||
L 3675 769
|
||||
L 4391 3500
|
||||
L 4966 3500
|
||||
L 4050 0
|
||||
L 3372 0
|
||||
L 2619 2869
|
||||
L 1863 0
|
||||
L 1184 0
|
||||
L 269 3500
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-62"/>
|
||||
<use xlink:href="#DejaVuSans-72" x="63.476562"/>
|
||||
<use xlink:href="#DejaVuSans-69" x="104.589844"/>
|
||||
<use xlink:href="#DejaVuSans-64" x="132.373047"/>
|
||||
<use xlink:href="#DejaVuSans-67" x="195.849609"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="259.326172"/>
|
||||
<use xlink:href="#DejaVuSans-2d" x="320.849609"/>
|
||||
<use xlink:href="#DejaVuSans-6e" x="356.933594"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="420.3125"/>
|
||||
<use xlink:href="#DejaVuSans-77" x="481.835938"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="matplotlib.axis_2">
|
||||
<g id="ytick_1">
|
||||
<g id="line2d_1">
|
||||
<path d="M 45.588 253.3425
|
||||
L 503.148 253.3425
|
||||
" clip-path="url(#p093f8268c7)" style="fill: none; stroke: #cccccc; stroke-opacity: 0.25; stroke-width: 0.8; stroke-linecap: round"/>
|
||||
</g>
|
||||
<g id="text_3">
|
||||
<!-- 0 -->
|
||||
<g style="fill: #262626" transform="translate(31.689 256.685812) scale(0.088 -0.088)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-30" d="M 2034 4250
|
||||
Q 1547 4250 1301 3770
|
||||
Q 1056 3291 1056 2328
|
||||
Q 1056 1369 1301 889
|
||||
Q 1547 409 2034 409
|
||||
Q 2525 409 2770 889
|
||||
Q 3016 1369 3016 2328
|
||||
Q 3016 3291 2770 3770
|
||||
Q 2525 4250 2034 4250
|
||||
z
|
||||
M 2034 4750
|
||||
Q 2819 4750 3233 4129
|
||||
Q 3647 3509 3647 2328
|
||||
Q 3647 1150 3233 529
|
||||
Q 2819 -91 2034 -91
|
||||
Q 1250 -91 836 529
|
||||
Q 422 1150 422 2328
|
||||
Q 422 3509 836 4129
|
||||
Q 1250 4750 2034 4750
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-30"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_2">
|
||||
<g id="line2d_2">
|
||||
<path d="M 45.588 207.528104
|
||||
L 503.148 207.528104
|
||||
" clip-path="url(#p093f8268c7)" style="fill: none; stroke: #cccccc; stroke-opacity: 0.25; stroke-width: 0.8; stroke-linecap: round"/>
|
||||
</g>
|
||||
<g id="text_4">
|
||||
<!-- 50 -->
|
||||
<g style="fill: #262626" transform="translate(26.09 210.871417) scale(0.088 -0.088)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-35" d="M 691 4666
|
||||
L 3169 4666
|
||||
L 3169 4134
|
||||
L 1269 4134
|
||||
L 1269 2991
|
||||
Q 1406 3038 1543 3061
|
||||
Q 1681 3084 1819 3084
|
||||
Q 2600 3084 3056 2656
|
||||
Q 3513 2228 3513 1497
|
||||
Q 3513 744 3044 326
|
||||
Q 2575 -91 1722 -91
|
||||
Q 1428 -91 1123 -41
|
||||
Q 819 9 494 109
|
||||
L 494 744
|
||||
Q 775 591 1075 516
|
||||
Q 1375 441 1709 441
|
||||
Q 2250 441 2565 725
|
||||
Q 2881 1009 2881 1497
|
||||
Q 2881 1984 2565 2268
|
||||
Q 2250 2553 1709 2553
|
||||
Q 1456 2553 1204 2497
|
||||
Q 953 2441 691 2322
|
||||
L 691 4666
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-35"/>
|
||||
<use xlink:href="#DejaVuSans-30" x="63.623047"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_3">
|
||||
<g id="line2d_3">
|
||||
<path d="M 45.588 161.713709
|
||||
L 503.148 161.713709
|
||||
" clip-path="url(#p093f8268c7)" style="fill: none; stroke: #cccccc; stroke-opacity: 0.25; stroke-width: 0.8; stroke-linecap: round"/>
|
||||
</g>
|
||||
<g id="text_5">
|
||||
<!-- 100 -->
|
||||
<g style="fill: #262626" transform="translate(20.491 165.057021) scale(0.088 -0.088)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-31" d="M 794 531
|
||||
L 1825 531
|
||||
L 1825 4091
|
||||
L 703 3866
|
||||
L 703 4441
|
||||
L 1819 4666
|
||||
L 2450 4666
|
||||
L 2450 531
|
||||
L 3481 531
|
||||
L 3481 0
|
||||
L 794 0
|
||||
L 794 531
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-31"/>
|
||||
<use xlink:href="#DejaVuSans-30" x="63.623047"/>
|
||||
<use xlink:href="#DejaVuSans-30" x="127.246094"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_4">
|
||||
<g id="line2d_4">
|
||||
<path d="M 45.588 115.899313
|
||||
L 503.148 115.899313
|
||||
" clip-path="url(#p093f8268c7)" style="fill: none; stroke: #cccccc; stroke-opacity: 0.25; stroke-width: 0.8; stroke-linecap: round"/>
|
||||
</g>
|
||||
<g id="text_6">
|
||||
<!-- 150 -->
|
||||
<g style="fill: #262626" transform="translate(20.491 119.242626) scale(0.088 -0.088)">
|
||||
<use xlink:href="#DejaVuSans-31"/>
|
||||
<use xlink:href="#DejaVuSans-35" x="63.623047"/>
|
||||
<use xlink:href="#DejaVuSans-30" x="127.246094"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_5">
|
||||
<g id="line2d_5">
|
||||
<path d="M 45.588 70.084918
|
||||
L 503.148 70.084918
|
||||
" clip-path="url(#p093f8268c7)" style="fill: none; stroke: #cccccc; stroke-opacity: 0.25; stroke-width: 0.8; stroke-linecap: round"/>
|
||||
</g>
|
||||
<g id="text_7">
|
||||
<!-- 200 -->
|
||||
<g style="fill: #262626" transform="translate(20.491 73.42823) scale(0.088 -0.088)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-32" d="M 1228 531
|
||||
L 3431 531
|
||||
L 3431 0
|
||||
L 469 0
|
||||
L 469 531
|
||||
Q 828 903 1448 1529
|
||||
Q 2069 2156 2228 2338
|
||||
Q 2531 2678 2651 2914
|
||||
Q 2772 3150 2772 3378
|
||||
Q 2772 3750 2511 3984
|
||||
Q 2250 4219 1831 4219
|
||||
Q 1534 4219 1204 4116
|
||||
Q 875 4013 500 3803
|
||||
L 500 4441
|
||||
Q 881 4594 1212 4672
|
||||
Q 1544 4750 1819 4750
|
||||
Q 2544 4750 2975 4387
|
||||
Q 3406 4025 3406 3419
|
||||
Q 3406 3131 3298 2873
|
||||
Q 3191 2616 2906 2266
|
||||
Q 2828 2175 2409 1742
|
||||
Q 1991 1309 1228 531
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-32"/>
|
||||
<use xlink:href="#DejaVuSans-30" x="63.623047"/>
|
||||
<use xlink:href="#DejaVuSans-30" x="127.246094"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_6">
|
||||
<g id="line2d_6">
|
||||
<path d="M 45.588 24.270522
|
||||
L 503.148 24.270522
|
||||
" clip-path="url(#p093f8268c7)" style="fill: none; stroke: #cccccc; stroke-opacity: 0.25; stroke-width: 0.8; stroke-linecap: round"/>
|
||||
</g>
|
||||
<g id="text_8">
|
||||
<!-- 250 -->
|
||||
<g style="fill: #262626" transform="translate(20.491 27.613835) scale(0.088 -0.088)">
|
||||
<use xlink:href="#DejaVuSans-32"/>
|
||||
<use xlink:href="#DejaVuSans-35" x="63.623047"/>
|
||||
<use xlink:href="#DejaVuSans-30" x="127.246094"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_9">
|
||||
<!-- Average latency [us] -->
|
||||
<g style="fill: #262626" transform="translate(14.4945 186.6015) rotate(-90) scale(0.096 -0.096)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-41" d="M 2188 4044
|
||||
L 1331 1722
|
||||
L 3047 1722
|
||||
L 2188 4044
|
||||
z
|
||||
M 1831 4666
|
||||
L 2547 4666
|
||||
L 4325 0
|
||||
L 3669 0
|
||||
L 3244 1197
|
||||
L 1141 1197
|
||||
L 716 0
|
||||
L 50 0
|
||||
L 1831 4666
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-76" d="M 191 3500
|
||||
L 800 3500
|
||||
L 1894 563
|
||||
L 2988 3500
|
||||
L 3597 3500
|
||||
L 2284 0
|
||||
L 1503 0
|
||||
L 191 3500
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-61" d="M 2194 1759
|
||||
Q 1497 1759 1228 1600
|
||||
Q 959 1441 959 1056
|
||||
Q 959 750 1161 570
|
||||
Q 1363 391 1709 391
|
||||
Q 2188 391 2477 730
|
||||
Q 2766 1069 2766 1631
|
||||
L 2766 1759
|
||||
L 2194 1759
|
||||
z
|
||||
M 3341 1997
|
||||
L 3341 0
|
||||
L 2766 0
|
||||
L 2766 531
|
||||
Q 2569 213 2275 61
|
||||
Q 1981 -91 1556 -91
|
||||
Q 1019 -91 701 211
|
||||
Q 384 513 384 1019
|
||||
Q 384 1609 779 1909
|
||||
Q 1175 2209 1959 2209
|
||||
L 2766 2209
|
||||
L 2766 2266
|
||||
Q 2766 2663 2505 2880
|
||||
Q 2244 3097 1772 3097
|
||||
Q 1472 3097 1187 3025
|
||||
Q 903 2953 641 2809
|
||||
L 641 3341
|
||||
Q 956 3463 1253 3523
|
||||
Q 1550 3584 1831 3584
|
||||
Q 2591 3584 2966 3190
|
||||
Q 3341 2797 3341 1997
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-20" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-6c" d="M 603 4863
|
||||
L 1178 4863
|
||||
L 1178 0
|
||||
L 603 0
|
||||
L 603 4863
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-79" d="M 2059 -325
|
||||
Q 1816 -950 1584 -1140
|
||||
Q 1353 -1331 966 -1331
|
||||
L 506 -1331
|
||||
L 506 -850
|
||||
L 844 -850
|
||||
Q 1081 -850 1212 -737
|
||||
Q 1344 -625 1503 -206
|
||||
L 1606 56
|
||||
L 191 3500
|
||||
L 800 3500
|
||||
L 1894 763
|
||||
L 2988 3500
|
||||
L 3597 3500
|
||||
L 2059 -325
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-5b" d="M 550 4863
|
||||
L 1875 4863
|
||||
L 1875 4416
|
||||
L 1125 4416
|
||||
L 1125 -397
|
||||
L 1875 -397
|
||||
L 1875 -844
|
||||
L 550 -844
|
||||
L 550 4863
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-75" d="M 544 1381
|
||||
L 544 3500
|
||||
L 1119 3500
|
||||
L 1119 1403
|
||||
Q 1119 906 1312 657
|
||||
Q 1506 409 1894 409
|
||||
Q 2359 409 2629 706
|
||||
Q 2900 1003 2900 1516
|
||||
L 2900 3500
|
||||
L 3475 3500
|
||||
L 3475 0
|
||||
L 2900 0
|
||||
L 2900 538
|
||||
Q 2691 219 2414 64
|
||||
Q 2138 -91 1772 -91
|
||||
Q 1169 -91 856 284
|
||||
Q 544 659 544 1381
|
||||
z
|
||||
M 1991 3584
|
||||
L 1991 3584
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-73" d="M 2834 3397
|
||||
L 2834 2853
|
||||
Q 2591 2978 2328 3040
|
||||
Q 2066 3103 1784 3103
|
||||
Q 1356 3103 1142 2972
|
||||
Q 928 2841 928 2578
|
||||
Q 928 2378 1081 2264
|
||||
Q 1234 2150 1697 2047
|
||||
L 1894 2003
|
||||
Q 2506 1872 2764 1633
|
||||
Q 3022 1394 3022 966
|
||||
Q 3022 478 2636 193
|
||||
Q 2250 -91 1575 -91
|
||||
Q 1294 -91 989 -36
|
||||
Q 684 19 347 128
|
||||
L 347 722
|
||||
Q 666 556 975 473
|
||||
Q 1284 391 1588 391
|
||||
Q 1994 391 2212 530
|
||||
Q 2431 669 2431 922
|
||||
Q 2431 1156 2273 1281
|
||||
Q 2116 1406 1581 1522
|
||||
L 1381 1569
|
||||
Q 847 1681 609 1914
|
||||
Q 372 2147 372 2553
|
||||
Q 372 3047 722 3315
|
||||
Q 1072 3584 1716 3584
|
||||
Q 2034 3584 2315 3537
|
||||
Q 2597 3491 2834 3397
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-5d" d="M 1947 4863
|
||||
L 1947 -844
|
||||
L 622 -844
|
||||
L 622 -397
|
||||
L 1369 -397
|
||||
L 1369 4416
|
||||
L 622 4416
|
||||
L 622 4863
|
||||
L 1947 4863
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-41"/>
|
||||
<use xlink:href="#DejaVuSans-76" x="62.533203"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="121.712891"/>
|
||||
<use xlink:href="#DejaVuSans-72" x="183.236328"/>
|
||||
<use xlink:href="#DejaVuSans-61" x="224.349609"/>
|
||||
<use xlink:href="#DejaVuSans-67" x="285.628906"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="349.105469"/>
|
||||
<use xlink:href="#DejaVuSans-20" x="410.628906"/>
|
||||
<use xlink:href="#DejaVuSans-6c" x="442.416016"/>
|
||||
<use xlink:href="#DejaVuSans-61" x="470.199219"/>
|
||||
<use xlink:href="#DejaVuSans-74" x="531.478516"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="570.6875"/>
|
||||
<use xlink:href="#DejaVuSans-6e" x="632.210938"/>
|
||||
<use xlink:href="#DejaVuSans-63" x="695.589844"/>
|
||||
<use xlink:href="#DejaVuSans-79" x="750.570312"/>
|
||||
<use xlink:href="#DejaVuSans-20" x="809.75"/>
|
||||
<use xlink:href="#DejaVuSans-5b" x="841.537109"/>
|
||||
<use xlink:href="#DejaVuSans-75" x="880.550781"/>
|
||||
<use xlink:href="#DejaVuSans-73" x="943.929688"/>
|
||||
<use xlink:href="#DejaVuSans-5d" x="996.029297"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="patch_3">
|
||||
<path d="M 68.466 253.3425
|
||||
L 251.49 253.3425
|
||||
L 251.49 233.838396
|
||||
L 68.466 233.838396
|
||||
z
|
||||
" clip-path="url(#p093f8268c7)" style="fill: #2f837f; stroke: #ffffff; stroke-width: 0.8; stroke-linejoin: miter"/>
|
||||
</g>
|
||||
<g id="patch_4">
|
||||
<path d="M 297.246 253.3425
|
||||
L 480.27 253.3425
|
||||
L 480.27 31.5825
|
||||
L 297.246 31.5825
|
||||
z
|
||||
" clip-path="url(#p093f8268c7)" style="fill: #2f837f; stroke: #ffffff; stroke-width: 0.8; stroke-linejoin: miter"/>
|
||||
</g>
|
||||
<g id="patch_5">
|
||||
<path d="M 159.978 253.3425
|
||||
L 159.978 253.3425
|
||||
L 159.978 253.3425
|
||||
L 159.978 253.3425
|
||||
z
|
||||
" clip-path="url(#p093f8268c7)" style="fill: #2f837f; stroke: #ffffff; stroke-width: 0.8; stroke-linejoin: miter"/>
|
||||
</g>
|
||||
<g id="patch_6">
|
||||
<path d="M 45.588 253.3425
|
||||
L 45.588 20.4945
|
||||
" style="fill: none; stroke: #cccccc; stroke-linejoin: miter; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="patch_7">
|
||||
<path d="M 45.588 253.3425
|
||||
L 503.148 253.3425
|
||||
" style="fill: none; stroke: #cccccc; stroke-linejoin: miter; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="text_10">
|
||||
<!-- Sockperf Application Latency -->
|
||||
<g style="fill: #262626" transform="translate(204.45675 14.4945) scale(0.096 -0.096)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-53" d="M 3425 4513
|
||||
L 3425 3897
|
||||
Q 3066 4069 2747 4153
|
||||
Q 2428 4238 2131 4238
|
||||
Q 1616 4238 1336 4038
|
||||
Q 1056 3838 1056 3469
|
||||
Q 1056 3159 1242 3001
|
||||
Q 1428 2844 1947 2747
|
||||
L 2328 2669
|
||||
Q 3034 2534 3370 2195
|
||||
Q 3706 1856 3706 1288
|
||||
Q 3706 609 3251 259
|
||||
Q 2797 -91 1919 -91
|
||||
Q 1588 -91 1214 -16
|
||||
Q 841 59 441 206
|
||||
L 441 856
|
||||
Q 825 641 1194 531
|
||||
Q 1563 422 1919 422
|
||||
Q 2459 422 2753 634
|
||||
Q 3047 847 3047 1241
|
||||
Q 3047 1584 2836 1778
|
||||
Q 2625 1972 2144 2069
|
||||
L 1759 2144
|
||||
Q 1053 2284 737 2584
|
||||
Q 422 2884 422 3419
|
||||
Q 422 4038 858 4394
|
||||
Q 1294 4750 2059 4750
|
||||
Q 2388 4750 2728 4690
|
||||
Q 3069 4631 3425 4513
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-6f" d="M 1959 3097
|
||||
Q 1497 3097 1228 2736
|
||||
Q 959 2375 959 1747
|
||||
Q 959 1119 1226 758
|
||||
Q 1494 397 1959 397
|
||||
Q 2419 397 2687 759
|
||||
Q 2956 1122 2956 1747
|
||||
Q 2956 2369 2687 2733
|
||||
Q 2419 3097 1959 3097
|
||||
z
|
||||
M 1959 3584
|
||||
Q 2709 3584 3137 3096
|
||||
Q 3566 2609 3566 1747
|
||||
Q 3566 888 3137 398
|
||||
Q 2709 -91 1959 -91
|
||||
Q 1206 -91 779 398
|
||||
Q 353 888 353 1747
|
||||
Q 353 2609 779 3096
|
||||
Q 1206 3584 1959 3584
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-6b" d="M 581 4863
|
||||
L 1159 4863
|
||||
L 1159 1991
|
||||
L 2875 3500
|
||||
L 3609 3500
|
||||
L 1753 1863
|
||||
L 3688 0
|
||||
L 2938 0
|
||||
L 1159 1709
|
||||
L 1159 0
|
||||
L 581 0
|
||||
L 581 4863
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-70" d="M 1159 525
|
||||
L 1159 -1331
|
||||
L 581 -1331
|
||||
L 581 3500
|
||||
L 1159 3500
|
||||
L 1159 2969
|
||||
Q 1341 3281 1617 3432
|
||||
Q 1894 3584 2278 3584
|
||||
Q 2916 3584 3314 3078
|
||||
Q 3713 2572 3713 1747
|
||||
Q 3713 922 3314 415
|
||||
Q 2916 -91 2278 -91
|
||||
Q 1894 -91 1617 61
|
||||
Q 1341 213 1159 525
|
||||
z
|
||||
M 3116 1747
|
||||
Q 3116 2381 2855 2742
|
||||
Q 2594 3103 2138 3103
|
||||
Q 1681 3103 1420 2742
|
||||
Q 1159 2381 1159 1747
|
||||
Q 1159 1113 1420 752
|
||||
Q 1681 391 2138 391
|
||||
Q 2594 391 2855 752
|
||||
Q 3116 1113 3116 1747
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-66" d="M 2375 4863
|
||||
L 2375 4384
|
||||
L 1825 4384
|
||||
Q 1516 4384 1395 4259
|
||||
Q 1275 4134 1275 3809
|
||||
L 1275 3500
|
||||
L 2222 3500
|
||||
L 2222 3053
|
||||
L 1275 3053
|
||||
L 1275 0
|
||||
L 697 0
|
||||
L 697 3053
|
||||
L 147 3053
|
||||
L 147 3500
|
||||
L 697 3500
|
||||
L 697 3744
|
||||
Q 697 4328 969 4595
|
||||
Q 1241 4863 1831 4863
|
||||
L 2375 4863
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-4c" d="M 628 4666
|
||||
L 1259 4666
|
||||
L 1259 531
|
||||
L 3531 531
|
||||
L 3531 0
|
||||
L 628 0
|
||||
L 628 4666
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-53"/>
|
||||
<use xlink:href="#DejaVuSans-6f" x="63.476562"/>
|
||||
<use xlink:href="#DejaVuSans-63" x="124.658203"/>
|
||||
<use xlink:href="#DejaVuSans-6b" x="179.638672"/>
|
||||
<use xlink:href="#DejaVuSans-70" x="237.548828"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="301.025391"/>
|
||||
<use xlink:href="#DejaVuSans-72" x="362.548828"/>
|
||||
<use xlink:href="#DejaVuSans-66" x="403.662109"/>
|
||||
<use xlink:href="#DejaVuSans-20" x="438.867188"/>
|
||||
<use xlink:href="#DejaVuSans-41" x="470.654297"/>
|
||||
<use xlink:href="#DejaVuSans-70" x="539.0625"/>
|
||||
<use xlink:href="#DejaVuSans-70" x="602.539062"/>
|
||||
<use xlink:href="#DejaVuSans-6c" x="666.015625"/>
|
||||
<use xlink:href="#DejaVuSans-69" x="693.798828"/>
|
||||
<use xlink:href="#DejaVuSans-63" x="721.582031"/>
|
||||
<use xlink:href="#DejaVuSans-61" x="776.5625"/>
|
||||
<use xlink:href="#DejaVuSans-74" x="837.841797"/>
|
||||
<use xlink:href="#DejaVuSans-69" x="877.050781"/>
|
||||
<use xlink:href="#DejaVuSans-6f" x="904.833984"/>
|
||||
<use xlink:href="#DejaVuSans-6e" x="966.015625"/>
|
||||
<use xlink:href="#DejaVuSans-20" x="1029.394531"/>
|
||||
<use xlink:href="#DejaVuSans-4c" x="1061.181641"/>
|
||||
<use xlink:href="#DejaVuSans-61" x="1116.894531"/>
|
||||
<use xlink:href="#DejaVuSans-74" x="1178.173828"/>
|
||||
<use xlink:href="#DejaVuSans-65" x="1217.382812"/>
|
||||
<use xlink:href="#DejaVuSans-6e" x="1278.90625"/>
|
||||
<use xlink:href="#DejaVuSans-63" x="1342.285156"/>
|
||||
<use xlink:href="#DejaVuSans-79" x="1397.265625"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="legend_1">
|
||||
<g id="patch_8">
|
||||
<path d="M 51.748 54.14225
|
||||
L 94.424 54.14225
|
||||
Q 96.184 54.14225 96.184 52.38225
|
||||
L 96.184 26.6545
|
||||
Q 96.184 24.8945 94.424 24.8945
|
||||
L 51.748 24.8945
|
||||
Q 49.988 24.8945 49.988 26.6545
|
||||
L 49.988 52.38225
|
||||
Q 49.988 54.14225 51.748 54.14225
|
||||
z
|
||||
" style="fill: #ffffff; opacity: 0.8; stroke: #cccccc; stroke-width: 0.8; stroke-linejoin: miter"/>
|
||||
</g>
|
||||
<g id="text_11">
|
||||
<!-- protocol -->
|
||||
<g style="fill: #262626" transform="translate(53.508 35.709) scale(0.096 -0.096)">
|
||||
<use xlink:href="#DejaVuSans-70"/>
|
||||
<use xlink:href="#DejaVuSans-72" x="63.476562"/>
|
||||
<use xlink:href="#DejaVuSans-6f" x="102.339844"/>
|
||||
<use xlink:href="#DejaVuSans-74" x="163.521484"/>
|
||||
<use xlink:href="#DejaVuSans-6f" x="202.730469"/>
|
||||
<use xlink:href="#DejaVuSans-63" x="263.912109"/>
|
||||
<use xlink:href="#DejaVuSans-6f" x="318.892578"/>
|
||||
<use xlink:href="#DejaVuSans-6c" x="380.074219"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="patch_9">
|
||||
<path d="M 53.828438 48.792125
|
||||
L 71.428438 48.792125
|
||||
L 71.428438 42.632125
|
||||
L 53.828438 42.632125
|
||||
z
|
||||
" style="fill: #2f837f; stroke: #ffffff; stroke-width: 0.8; stroke-linejoin: miter"/>
|
||||
</g>
|
||||
<g id="text_12">
|
||||
<!-- tcp -->
|
||||
<g style="fill: #262626" transform="translate(78.468438 48.792125) scale(0.088 -0.088)">
|
||||
<use xlink:href="#DejaVuSans-74"/>
|
||||
<use xlink:href="#DejaVuSans-63" x="39.208984"/>
|
||||
<use xlink:href="#DejaVuSans-70" x="94.189453"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="p093f8268c7">
|
||||
<rect x="45.588" y="20.4945" width="457.56" height="232.848"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
BIN
documentation/thesis/figures/benchmark/throughput.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
1181
documentation/thesis/figures/benchmark/throughput.svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
documentation/thesis/figures/benchmark/udp_jitter_loss.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
1231
documentation/thesis/figures/benchmark/udp_jitter_loss.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
109
documentation/thesis/main.tex
Normal file
@@ -0,0 +1,109 @@
|
||||
\documentclass[a4paper,11pt,headlines=2.1,bibliography=totoc,numbers=noenddot]{scrreport}
|
||||
|
||||
\usepackage{setspace}
|
||||
|
||||
|
||||
%-----------------------------------------------------------------------
|
||||
\input{00-commands}
|
||||
\input{00-packages}
|
||||
\input{00-settings}
|
||||
\setlength{\aboverulesep}{0pt}
|
||||
\setlength{\belowrulesep}{0pt}
|
||||
|
||||
% fixes inserted empty space if text does not fit
|
||||
\raggedbottom
|
||||
|
||||
%\bibliography{ba}
|
||||
\addbibresource{ba.bib}
|
||||
%-----------------------------------------------------------------------
|
||||
|
||||
\newcommand{\thetitle}
|
||||
{Design and Implementation of a Web-Based Platform for MITM Traffic Capture, Correlation, and Analysis}
|
||||
\newcommand{\theauthor}{Marcus Jan Almert}
|
||||
|
||||
\title{\thetitle}
|
||||
\author{\theauthor}
|
||||
|
||||
\hypersetup{
|
||||
pdftitle={\thetitle},
|
||||
pdfauthor={\theauthor},
|
||||
pdfsubject={Master's Thesis},
|
||||
pdfcreator={LaTeX},
|
||||
colorlinks=true,
|
||||
linkcolor=black,
|
||||
citecolor=black,
|
||||
urlcolor=blue
|
||||
}
|
||||
|
||||
\clearpairofpagestyles
|
||||
\ihead{\headmark}
|
||||
\ohead{}
|
||||
\cfoot*{\pagemark}
|
||||
|
||||
\RedeclareSectionCommand[
|
||||
beforeskip=-1sp
|
||||
]{chapter}
|
||||
|
||||
%-----------------------------------------------------------------------
|
||||
\begin{document}
|
||||
%-----------------------------------------------------------------------
|
||||
|
||||
\pagenumbering{gobble}
|
||||
|
||||
\include{00-title}
|
||||
|
||||
|
||||
|
||||
\makeatletter
|
||||
\let\ACplacelabel\AC@placelabel
|
||||
\makeatother
|
||||
|
||||
\pagenumbering{Roman}
|
||||
|
||||
\include{00-abstract}
|
||||
%\include{acknowledgements}
|
||||
|
||||
\include{00-oath}
|
||||
|
||||
\pagenumbering{arabic}
|
||||
|
||||
\begin{spacing}{1.05}
|
||||
\tableofcontents
|
||||
\end{spacing}
|
||||
|
||||
\include{00-acronyms}
|
||||
|
||||
\include{02-preliminaries}
|
||||
|
||||
%\listoftables
|
||||
%\listoffigures
|
||||
%\lstlistoflistings
|
||||
|
||||
|
||||
%-----------------------------------------------------------------------
|
||||
|
||||
%\printacronyms[heading=chapter*]
|
||||
%\markright{Acronyms}
|
||||
%\newpage
|
||||
|
||||
|
||||
%-----------------------------------------------------------------------
|
||||
\pagestyle{scrheadings}
|
||||
%\include{01-introduction}
|
||||
%\include{02-preliminaries}
|
||||
|
||||
|
||||
|
||||
%-----------------------------------------------------------------------
|
||||
|
||||
\newpage
|
||||
\emergencystretch=4em
|
||||
|
||||
\printbibliography
|
||||
\newpage
|
||||
\appendix
|
||||
\include{appendix}
|
||||
|
||||
%-----------------------------------------------------------------------
|
||||
|
||||
\end{document}
|
||||
50
documentation/thesis/notes/preliminaries ideas.md
Normal file
@@ -0,0 +1,50 @@
|
||||
Your project is already much richer than a generic “MITM tool.” From the code, it is really a transparent inline Layer-2 observation and manipulation platform: it creates a Linux bridge with STP disabled, manages bridge member behavior, captures traffic either via `AF_PACKET` or `tc/eBPF`, correlates packet observations with kernel telemetry, applies `nftables`/`NFQUEUE` manipulation, and builds higher-level traffic intelligence on top of that. You can see those pillars in [network_api.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/api/network_api.py:498), [bridge_link_state_manager.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/utilities/bridge_link_state_manager.py:86), [network_sniffer.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/network_sniffer.py:774), [bridge_telemetry.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/utilities/bridge_telemetry.py:22), [packet_tracker.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/utilities/packet_tracker.py:238), [nftables_api.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/api/nftables_api.py:47), [packet_scripting_api.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/api/packet_scripting_api.py:2), and [analysis_api.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/api/analysis_api.py:145). Compared with your current [02-preliminaries.tex](/home/marcus/Desktop/Masterarbeit/mitm-webserver/documentation/thesis/02-preliminaries.tex:1), the thesis would benefit from moving beyond a mainly OSI-focused introduction.
|
||||
|
||||
**What I would definitely add to the preliminaries**
|
||||
|
||||
- `Transparent Layer-2 MITM / inline bridge systems`: difference between routed MITM, proxying, TAP/SPAN capture, and transparent bridging.
|
||||
- `Ethernet switching and Linux bridge internals`: MAC learning, forwarding database, flooding, broadcast domains, unknown unicast, VLAN awareness, STP/RSTP, and why disabling STP matters for your setup.
|
||||
- `Stealth / transparency criteria`: what “hidden” means technically in your thesis. For example: no IP hop added, no TTL change, minimal forwarding delay, preserved link properties, no obvious protocol artifacts.
|
||||
- `Link-state propagation and fail behavior`: your code actively mirrors link failures and synchronizes MTU / speed / duplex / autoneg, which is unusually relevant for an inline appliance and worth explaining conceptually.
|
||||
- `Linux packet-processing path`: NIC, driver, `sk_buff`, bridge forwarding path, netfilter hooks, `tc` ingress/egress, and where capture/manipulation can be attached.
|
||||
- `AF_PACKET raw sockets`: why they are suitable for passive L2 capture, and their trade-offs.
|
||||
- `nftables and the bridge family`: tables, chains, hooks, priorities, verdicts, and why bridge-family filtering is important in a transparent bridge scenario.
|
||||
- `NFQUEUE`: how packets are punted to user space, latency/performance implications, and the difference between passive observation and inline modification.
|
||||
- `eBPF at tc`: attach points, maps, helpers, packet metadata access, and why eBPF is useful for low-overhead telemetry and packet correlation.
|
||||
- `Packet marking and correlation`: your project uses `skb->mark`-based packet IDs and verdict bits, which is a very strong thesis concept because it ties kernel events to captured packets ([mark_packet_id.c](/home/marcus/Desktop/Masterarbeit/mitm-webserver/tools/ebpf/mark_packet_id.c:20)).
|
||||
- `Protocol parsing and enrichment`: Ethernet, ARP, IPv4/IPv6, TCP/UDP/ICMP, plus DPI/enrichment with Scapy and `tshark` ([tshark_manager.py](/home/marcus/Desktop/Masterarbeit/mitm-webserver/backend/src/utilities/tshark_manager.py:690)).
|
||||
- `Flow/conversation reconstruction`: packet identity, deduplication, ingress/egress inference, flow IDs, conversations, and discovery traffic classification.
|
||||
- `Threat model and limitations`: what kinds of traffic can be observed/manipulated, how encryption limits analysis, and where the bridge can still become detectable.
|
||||
|
||||
**Very thesis-relevant concepts that are specific to your implementation**
|
||||
|
||||
- `Bridge transparency vs detectability`
|
||||
- `Bridge member synchronization`
|
||||
- `Event-driven network control via netlink / pyroute2`
|
||||
- `Hybrid observation pipeline: raw capture + kernel telemetry + DPI enrichment`
|
||||
- `Correlation of data-plane and control-plane evidence`
|
||||
- `Programmable packet handling with nftables + NFQUEUE scripts`
|
||||
- `Traffic-intelligence extraction from passive observations`
|
||||
- `Discovery protocol analysis`: ARP, DHCP, mDNS, SSDP, LLMNR, NBNS, ICMPv6 discovery
|
||||
|
||||
**What I would keep short or move to implementation**
|
||||
|
||||
- FastAPI, React, WebSockets, Docker, and general UI architecture
|
||||
- PostgreSQL schema details
|
||||
- systemd service deployment details for scripts
|
||||
|
||||
Those matter, but they feel more like implementation chapter material than preliminaries unless your thesis is explicitly about the full software platform architecture.
|
||||
|
||||
**A strong chapter structure could be**
|
||||
|
||||
1. Communication models: brief OSI and TCP/IP mapping
|
||||
2. Ethernet and transparent bridging
|
||||
3. Linux bridge architecture and link-state behavior
|
||||
4. Linux packet path: raw sockets, netfilter, `tc`, and `sk_buff`
|
||||
5. `nftables`, bridge-family filtering, and `NFQUEUE`
|
||||
6. eBPF for packet telemetry and correlation
|
||||
7. Packet parsing, DPI, and flow reconstruction
|
||||
8. Stealth, detectability, and operational limitations
|
||||
9. Ethical and legal boundaries of MITM experimentation
|
||||
|
||||
If you want, I can turn this directly into a thesis-ready rewrite for [02-preliminaries.tex](/home/marcus/Desktop/Masterarbeit/mitm-webserver/documentation/thesis/02-preliminaries.tex:1) with subsection titles and short starter paragraphs.
|
||||
136
documentation/thesis/notes/style_baseline_bachelor_thesis.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Bachelor Thesis Style Baseline
|
||||
|
||||
Source: Marcus Jan Almert, "An Investigation of the Security of Smart Doorbells", bachelor's thesis, 2022.
|
||||
|
||||
Use this note as the baseline when drafting or revising the master's thesis. The goal is not to copy sentences from the bachelor's thesis, but to preserve its academic voice, explanatory rhythm, and technical clarity.
|
||||
|
||||
## Overall Voice
|
||||
|
||||
- Formal, technical, and objective.
|
||||
- Prefer an impersonal academic perspective: "this thesis", "the present thesis", "the analysis", "the developed system".
|
||||
- Avoid first-person singular. First-person plural is rare and should only be used when the surrounding section genuinely calls for it.
|
||||
- Use present tense for general concepts, protocols, system properties, and chapter purpose.
|
||||
- Use past tense for performed experiments, observations, implementations, and measurements.
|
||||
- Use cautious language when evidence is partial: "could", "may", "potentially", "was not proven", "was observed".
|
||||
|
||||
## Chapter and Section Openings
|
||||
|
||||
The bachelor's thesis often starts chapters with a short roadmap:
|
||||
|
||||
- State what the chapter or section explains.
|
||||
- Then list the sequence of topics with "First", "Next", "Then", "Lastly", or "Thereafter".
|
||||
- Keep the opening practical and close to the technical purpose of the chapter.
|
||||
|
||||
Preferred pattern:
|
||||
|
||||
> The following chapter explains essential concepts used in this thesis. First, ..., Next, ..., Lastly, ...
|
||||
|
||||
For the master's thesis, prefer this direct roadmap style over broader phrases such as "foundational concepts underlying the research".
|
||||
|
||||
## Paragraph Rhythm
|
||||
|
||||
- Begin paragraphs with a clear topic sentence.
|
||||
- Follow with mechanism, implementation detail, or evidence.
|
||||
- End with consequence, relevance, or transition to the next point.
|
||||
- Background paragraphs are medium length and explanatory.
|
||||
- Analysis and evaluation paragraphs are more compact and evidence-driven.
|
||||
- Use lists only when they make capabilities, attack effects, requirements, or result categories easier to scan.
|
||||
|
||||
## Common Transitions
|
||||
|
||||
Useful connective phrases matching the bachelor's thesis style:
|
||||
|
||||
- "For this purpose, ..."
|
||||
- "Using this setup, ..."
|
||||
- "As described in Section ..."
|
||||
- "In the following section, ..."
|
||||
- "Furthermore, ..."
|
||||
- "Additionally, ..."
|
||||
- "However, ..."
|
||||
- "In contrast, ..."
|
||||
- "Consequently, ..."
|
||||
- "Therefore, ..."
|
||||
- "Lastly, ..."
|
||||
- "This allows ..."
|
||||
- "This is evidenced by ..."
|
||||
- "An example of ... is shown in ..."
|
||||
- "Similar to ..."
|
||||
|
||||
Use these naturally; do not over-stack them in every paragraph.
|
||||
|
||||
## Technical Explanation Style
|
||||
|
||||
- Define a concept before relying on it later.
|
||||
- Introduce acronyms on first use, then use the acronym consistently.
|
||||
- In LaTeX, introduce acronyms with the `acronym` package using `\ac{...}` or `\acp{...}`. Do not write acronym short forms manually in running text when an acronym entry exists.
|
||||
- Write tool names, command names, kernel symbols, hook names, protocol constants, and code-level identifiers in `\texttt{...}`. This includes names such as `\texttt{nftables}`, `\texttt{tc}`, `\texttt{sk_buff}`, and `\texttt{AF_PACKET}`. Acronym short forms such as `NFQUEUE` should still be produced with `\ac{NFQUEUE}`, because the thesis settings render acronym short forms in typewriter font automatically.
|
||||
- Prefer exact technical nouns over stylistic synonym changes.
|
||||
- When explaining protocols or implementation paths, move from general role to concrete fields, functions, tools, or messages.
|
||||
- Use listings, tables, and figures to make protocol messages, APIs, measurements, and system paths concrete.
|
||||
- Mention tool names and versions when they matter for reproducibility.
|
||||
|
||||
## Evidence and Claim Strength
|
||||
|
||||
- Tie claims to observations, measurements, listings, figures, tables, or cited sources.
|
||||
- Avoid unsupported adjectives such as "robust", "novel", "seamless", or "powerful" unless the section proves them.
|
||||
- Distinguish clearly between demonstrated findings and plausible implications.
|
||||
- For security-related statements, state the adversary capability or system assumption before the impact.
|
||||
|
||||
## Citation Style
|
||||
|
||||
- Use numeric citation style through LaTeX references.
|
||||
- Place citations near the factual claim they support.
|
||||
- Standards, protocol details, and external tool behavior should be cited.
|
||||
- Implementation descriptions and own measurements usually do not need external citations, but should reference the relevant listing, figure, table, or section.
|
||||
|
||||
## Analysis Section Pattern
|
||||
|
||||
The bachelor's thesis uses a repeatable analysis rhythm:
|
||||
|
||||
1. Introduce the investigated object, version, setup, or scope.
|
||||
2. Describe the observed behavior.
|
||||
3. Explain the technical mechanism.
|
||||
4. Demonstrate the issue or result with concrete evidence.
|
||||
5. State the impact or relevance.
|
||||
6. If appropriate, compare to earlier sections.
|
||||
|
||||
For the master's thesis, this maps well to platform features and evaluation sections:
|
||||
|
||||
1. Introduce the component or measurement scenario.
|
||||
2. Describe where it sits in the packet path or application architecture.
|
||||
3. Explain how it was implemented or measured.
|
||||
4. Show the relevant data, interface, figure, or listing.
|
||||
5. State what this means for correctness, timing, usability, or security analysis.
|
||||
|
||||
## Summary and Future Work Pattern
|
||||
|
||||
The conclusion style is concise and retrospective:
|
||||
|
||||
- Restate the thesis goal.
|
||||
- Summarize the method.
|
||||
- Summarize the main findings or contributions.
|
||||
- Name limitations or unresolved questions.
|
||||
- Present future work as concrete continuation paths.
|
||||
|
||||
Prefer "A future work possibility would be ..." or "Another future work possibility would be ..." when matching the older style, but use it sparingly to avoid repetition.
|
||||
|
||||
## Phrases to Prefer
|
||||
|
||||
- "The goal of this thesis was ..."
|
||||
- "To achieve this, ..."
|
||||
- "The analysis considered ..."
|
||||
- "It was shown that ..."
|
||||
- "It was discovered that ..."
|
||||
- "The following section focuses on ..."
|
||||
- "For the present thesis, ..."
|
||||
- "This is particularly important because ..."
|
||||
- "In preparation for ..."
|
||||
- "The captured data was then examined for ..."
|
||||
|
||||
## Phrases to Avoid or Reduce
|
||||
|
||||
- Marketing-style claims: "seamless", "cutting-edge", "state-of-the-art" unless cited and justified.
|
||||
- Overly abstract openings: "This chapter establishes the theoretical foundation for ..."
|
||||
- Personal narration: "I implemented", "we wanted to".
|
||||
- Unqualified certainty for uncertain findings: use cautious modality where appropriate.
|
||||
- Long rhetorical motivation before the technical problem is clear.
|
||||
774
frontend/package-lock.json
generated
@@ -11,8 +11,12 @@
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/d3-sankey": "^0.12.5",
|
||||
"antd": "^6.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
@@ -2280,6 +2284,283 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-sankey": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz",
|
||||
"integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-shape": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-sankey/node_modules/@types/d3-path": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
|
||||
"integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-sankey/node_modules/@types/d3-shape": {
|
||||
"version": "1.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
|
||||
"integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2287,6 +2568,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
@@ -2885,6 +3172,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/compute-scroll-into-view": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||
@@ -2970,6 +3266,448 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-sankey": {
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
|
||||
"integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "1 - 2",
|
||||
"d3-shape": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-sankey/node_modules/d3-array": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
|
||||
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"internmap": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-sankey/node_modules/d3-path": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
|
||||
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-sankey/node_modules/d3-shape": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
|
||||
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-path": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-sankey/node_modules/internmap": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
|
||||
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
@@ -3092,6 +3830,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -4077,9 +4824,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@@ -4153,6 +4898,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -5409,6 +6163,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
|
||||
@@ -5451,6 +6211,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -5510,9 +6276,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.3",
|
||||
|
||||
@@ -15,8 +15,12 @@
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/d3-sankey": "^0.12.5",
|
||||
"antd": "^6.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
|
||||
@@ -4,3 +4,19 @@
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ant-notification-notice {
|
||||
padding: 0px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-notification {
|
||||
left: 70% !important;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.ant-notification-topRight,
|
||||
.ant-notification-topLeft {
|
||||
left: 70% !important;
|
||||
right: auto !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Layout } from 'antd';
|
||||
import { Layout, notification } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import './App.css';
|
||||
@@ -8,6 +8,14 @@ import Sidebar from './Layout/Sidebar';
|
||||
|
||||
const { Content, Footer } = Layout;
|
||||
|
||||
notification.config({
|
||||
placement: 'topRight', // topLeft | topRight | bottomLeft | bottomRight
|
||||
top: 20, // distance from top
|
||||
bottom: 24, // distance from bottom
|
||||
duration: 3, // auto close time (seconds)
|
||||
maxCount: 3, // max notifications shown
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Footer } from 'antd/es/layout/layout';
|
||||
|
||||
export const AppFooter: React.FC = () => {
|
||||
return <Footer>MitM Webserver App by Marcus Almert ©2025</Footer>;
|
||||
return <Footer>MitM Webserver App by Marcus Almert ©2025-2026</Footer>;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// src/components/Sidebar.tsx
|
||||
import { ApartmentOutlined, ApiOutlined, HomeOutlined, InfoCircleOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
AreaChartOutlined,
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
MonitorOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import FirewallIcon from '../icons/FirewallIcon';
|
||||
import TerminalIcon from '../icons/TerminalIcon';
|
||||
import { PATHS } from '../routes';
|
||||
import '../theme/layout.less';
|
||||
@@ -12,13 +20,14 @@ const { Sider } = Layout;
|
||||
const menuItems = [
|
||||
{ key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' },
|
||||
{ key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' },
|
||||
{ key: PATHS.SNIFFING, icon: <ApiOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' },
|
||||
{ key: PATHS.FIREWALL, icon: <FirewallIcon style={{ fontSize: '18px' }} />, label: 'Firewall' },
|
||||
{ key: PATHS.SNIFFING, icon: <MonitorOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' },
|
||||
{
|
||||
key: PATHS.SCRIPTING,
|
||||
icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />,
|
||||
label: 'Scripting',
|
||||
},
|
||||
{ key: PATHS.FIREWALL, icon: <ApiOutlined style={{ fontSize: '18px' }} />, label: 'Firewall' },
|
||||
{ key: PATHS.ANALYSIS, icon: <AreaChartOutlined style={{ fontSize: '18px' }} />, label: 'Analysis' },
|
||||
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
|
||||
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
|
||||
];
|
||||
|
||||
@@ -1,230 +1,427 @@
|
||||
// src/apiClient.ts
|
||||
import axios from "axios";
|
||||
import { CreateRuleRequest, ExecResult, RulesetModel } from "../types/firewall";
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
AnomalyAnalysisResponse,
|
||||
ConversationAnalysisResponse,
|
||||
ConversationFlowDetailResponse,
|
||||
DiscoveryAnalysisResponse,
|
||||
HostIntelligenceAnalysisResponse,
|
||||
InterfaceHostAnalysisResponse,
|
||||
InterfaceHostProtocolAnalysisResponse,
|
||||
InterfaceProtocolPathAnalysisResponse,
|
||||
} from '../types/analysis';
|
||||
import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
|
||||
import {
|
||||
BridgeCreateRequest,
|
||||
BridgeLinkStateEnableRequest,
|
||||
BridgeLinkStateWatcherStatus,
|
||||
BridgeInfo,
|
||||
BridgeRemoveRequest,
|
||||
FullState,
|
||||
InterfaceResetDefaultsRequest,
|
||||
InterfaceResetDefaultsResponse,
|
||||
InterfaceInfo,
|
||||
RouteInfo,
|
||||
} from "../types/network";
|
||||
import { EnableRequest, ScriptInfo } from "../types/scripting";
|
||||
} from '../types/network';
|
||||
import {
|
||||
DeleteResult,
|
||||
EnableRequest,
|
||||
OperationResult,
|
||||
RequirementsDeleteResult,
|
||||
RequirementsUploadResult,
|
||||
ScriptInfo,
|
||||
ScriptUploadResponse,
|
||||
ScriptWithStatus,
|
||||
StatusForNameResponse,
|
||||
} from '../types/scripting';
|
||||
import { FetchPacketsResponse } from '../types/packets';
|
||||
import {
|
||||
SnifferStartRequest,
|
||||
SnifferStartResponse,
|
||||
SnifferStatusResponse,
|
||||
} from "../types/sniffer";
|
||||
SnifferStopRequest,
|
||||
SnifferStopResponse,
|
||||
} from '../types/sniffer';
|
||||
|
||||
const BASE = "http://mitm.lan/api";
|
||||
const BASE = 'http://mitm.lan/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: BASE,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Normalize FastAPI errors here
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// FastAPI HTTPException format
|
||||
const detail =
|
||||
error?.response?.data?.detail ??
|
||||
error?.response?.data?.message ??
|
||||
error.message ??
|
||||
"Unknown error";
|
||||
|
||||
// Always reject with a standard Error
|
||||
const detail = error?.response?.data?.detail ?? error?.response?.data?.message ?? error.message ?? 'Unknown error';
|
||||
return Promise.reject(new Error(detail));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
/* -------------------------
|
||||
Basic endpoints
|
||||
------------------------- */
|
||||
|
||||
export const fetchHello = async (): Promise<any> => {
|
||||
const res = await api.get("/hello");
|
||||
const res = await api.get('/hello');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchVersions = async (): Promise<any> => {
|
||||
const res = await api.get("/versions");
|
||||
const res = await api.get('/versions');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/* -------------------------
|
||||
Network queries (existing)
|
||||
------------------------- */
|
||||
|
||||
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => {
|
||||
const res = await api.get<InterfaceInfo[]>("/network/interfaces");
|
||||
const res = await api.get<InterfaceInfo[]>('/network/interfaces');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchLinks = async (): Promise<InterfaceInfo[]> => {
|
||||
const res = await api.get<InterfaceInfo[]>("/network/links");
|
||||
const res = await api.get<InterfaceInfo[]>('/network/links');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchRoutes = async (): Promise<RouteInfo[]> => {
|
||||
const res = await api.get<RouteInfo[]>("/network/routes");
|
||||
const res = await api.get<RouteInfo[]>('/network/routes');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchBridges = async (): Promise<BridgeInfo[]> => {
|
||||
const res = await api.get<BridgeInfo[]>("/network/bridges");
|
||||
const res = await api.get<BridgeInfo[]>('/network/bridges');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchFullState = async (): Promise<FullState> => {
|
||||
const res = await api.get<FullState>("/network/full-state");
|
||||
const res = await api.get<FullState>('/network/full-state');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const resetInterfaceDefaults = async (
|
||||
req: InterfaceResetDefaultsRequest,
|
||||
): Promise<InterfaceResetDefaultsResponse> => {
|
||||
const res = await api.post<InterfaceResetDefaultsResponse>('/network/interfaces/reset-defaults', req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const getNetworkStateWebSocketUrl = (): string => {
|
||||
const url = new URL(BASE);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.pathname = `${url.pathname.replace(/\/$/, '')}/network/ws/state`;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const createBridge = async (req: BridgeCreateRequest) => {
|
||||
const res = await api.post("/network/bridge/create", req);
|
||||
const res = await api.post('/network/bridge/create', req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const removeBridge = async (req: BridgeRemoveRequest) => {
|
||||
const res = await api.post("/network/bridge/remove", req);
|
||||
const res = await api.post('/network/bridge/remove', req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const enableBridgeLinkStateWatcher = async (
|
||||
bridgeName: string,
|
||||
req: BridgeLinkStateEnableRequest,
|
||||
): Promise<BridgeLinkStateWatcherStatus> => {
|
||||
const res = await api.post<BridgeLinkStateWatcherStatus>(
|
||||
`/network/bridge/${encodeURIComponent(bridgeName)}/link-state-watcher/enable`,
|
||||
req,
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/* -------------------------
|
||||
Sniffer
|
||||
------------------------- */
|
||||
export const disableBridgeLinkStateWatcher = async (bridgeName: string): Promise<BridgeLinkStateWatcherStatus> => {
|
||||
const res = await api.post<BridgeLinkStateWatcherStatus>(
|
||||
`/network/bridge/${encodeURIComponent(bridgeName)}/link-state-watcher/disable`,
|
||||
{},
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const startSniffer = async (payload: SnifferStartRequest): Promise<SnifferStartResponse> => {
|
||||
const res = await api.post<SnifferStartResponse>('/sniffer/start', payload);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const stopSniffer = async (body?: SnifferStopRequest): Promise<SnifferStopResponse> => {
|
||||
const res = await api.post<SnifferStopResponse>('/sniffer/stop', body ?? {});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const stopSnifferByInterface = async (iface: string): Promise<SnifferStopResponse> => {
|
||||
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?interface=${encodeURIComponent(iface)}`, {});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const stopSnifferByBridge = async (bridge: string): Promise<SnifferStopResponse> => {
|
||||
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?bridge=${encodeURIComponent(bridge)}`, {});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
|
||||
const res = await api.get<SnifferStatusResponse>("/sniffer/status");
|
||||
const res = await api.get<SnifferStatusResponse>('/sniffer/status');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/* export const snifferStart = async (
|
||||
req: SnifferStartRequest
|
||||
): Promise<SnifferStartResponse> => {
|
||||
const res = await api.post<SnifferStartResponse>("/sniffer/start", req);
|
||||
export const fetchPackets = async (limit = 100): Promise<FetchPacketsResponse> => {
|
||||
const res = await api.get<FetchPacketsResponse>('/packets/packets', { params: { limit } });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const snifferStop = async (): Promise<SnifferStopResponse> => {
|
||||
const res = await api.post<SnifferStopResponse>("/sniffer/stop");
|
||||
return res.data;
|
||||
export const getPacketsWebSocketUrl = (subscribeRecent = 0): string => {
|
||||
const url = new URL(BASE);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.pathname = `${url.pathname.replace(/\/$/, '')}/packets/ws/packets`;
|
||||
if (subscribeRecent > 0) {
|
||||
url.searchParams.set('subscribe_recent', String(subscribeRecent));
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
*/
|
||||
/* -------------------------
|
||||
Packets
|
||||
------------------------- */
|
||||
|
||||
export const fetchPackets = async (limit = 100): Promise<any> => {
|
||||
// limit default mirrors OpenAPI default
|
||||
const res = await api.get("/packets/packets", { params: { limit } });
|
||||
export const clearPackets = async (): Promise<any> => {
|
||||
const res = await api.delete('/packets/packets');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/* -------------------------
|
||||
Firewall
|
||||
------------------------- */
|
||||
|
||||
/**
|
||||
* GET /firewall/rules
|
||||
* Returns: { ruleset: RulesetModel | string | null }
|
||||
* - If the server returns a raw textual fallback (string), the caller should handle it.
|
||||
*/
|
||||
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | null }> => {
|
||||
const res = await api.get<{ ruleset: RulesetModel | string | null }>("/firewall/rules");
|
||||
export const fetchInterfaceHostAnalysis = async (
|
||||
sinceMinutes: number | null = null,
|
||||
limitPerInterface = 100,
|
||||
): Promise<InterfaceHostAnalysisResponse> => {
|
||||
const res = await api.get<InterfaceHostAnalysisResponse>('/analysis/interface-hosts', {
|
||||
params: {
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit_per_interface: limitPerInterface,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /firewall/rules/{handle}?family=...&table=...&chain=...
|
||||
* On success the backend returns 204 No Content. This function resolves to void.
|
||||
*/
|
||||
export const deleteRule = async (
|
||||
handle: number,
|
||||
family: string,
|
||||
table: string,
|
||||
chain: string
|
||||
): Promise<void> => {
|
||||
export const fetchInterfaceHostProtocolAnalysis = async (
|
||||
sinceMinutes: number | null = null,
|
||||
limitPerInterface = 50,
|
||||
limitProtocolsPerHost = 12,
|
||||
): Promise<InterfaceHostProtocolAnalysisResponse> => {
|
||||
const res = await api.get<InterfaceHostProtocolAnalysisResponse>('/analysis/interface-host-protocols', {
|
||||
params: {
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit_per_interface: limitPerInterface,
|
||||
limit_protocols_per_host: limitProtocolsPerHost,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchInterfaceProtocolPathAnalysis = async (
|
||||
sinceMinutes: number | null = null,
|
||||
limitPaths = 500,
|
||||
): Promise<InterfaceProtocolPathAnalysisResponse> => {
|
||||
const res = await api.get<InterfaceProtocolPathAnalysisResponse>('/analysis/interface-protocol-paths', {
|
||||
params: {
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit_paths: limitPaths,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchConversationAnalysis = async (
|
||||
sinceMinutes: number | null = null,
|
||||
limit = 300,
|
||||
): Promise<ConversationAnalysisResponse> => {
|
||||
const res = await api.get<ConversationAnalysisResponse>('/analysis/conversations', {
|
||||
params: {
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchConversationFlowDetail = async ({
|
||||
flowId,
|
||||
srcIpAddress,
|
||||
srcMacAddress,
|
||||
dstIpAddress,
|
||||
dstMacAddress,
|
||||
srcPort,
|
||||
dstPort,
|
||||
protocol,
|
||||
sinceMinutes = null,
|
||||
limitPackets = 1500,
|
||||
}: {
|
||||
flowId?: string | null;
|
||||
srcIpAddress?: string | null;
|
||||
srcMacAddress?: string | null;
|
||||
dstIpAddress?: string | null;
|
||||
dstMacAddress?: string | null;
|
||||
srcPort?: number | null;
|
||||
dstPort?: number | null;
|
||||
protocol?: string | null;
|
||||
sinceMinutes?: number | null;
|
||||
limitPackets?: number;
|
||||
}): Promise<ConversationFlowDetailResponse> => {
|
||||
const res = await api.get<ConversationFlowDetailResponse>('/analysis/conversation-flow-detail', {
|
||||
params: {
|
||||
flow_id: flowId ?? undefined,
|
||||
src_ip_address: srcIpAddress ?? undefined,
|
||||
src_mac_address: srcMacAddress ?? undefined,
|
||||
dst_ip_address: dstIpAddress ?? undefined,
|
||||
dst_mac_address: dstMacAddress ?? undefined,
|
||||
src_port: srcPort ?? undefined,
|
||||
dst_port: dstPort ?? undefined,
|
||||
protocol: protocol ?? undefined,
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit_packets: limitPackets,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchHostIntelligenceAnalysis = async (
|
||||
sinceMinutes: number | null = null,
|
||||
limitHosts = 40,
|
||||
): Promise<HostIntelligenceAnalysisResponse> => {
|
||||
const res = await api.get<HostIntelligenceAnalysisResponse>('/analysis/host-intelligence', {
|
||||
params: {
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit_hosts: limitHosts,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchDiscoveryAnalysis = async (
|
||||
sinceMinutes: number | null = null,
|
||||
limit = 300,
|
||||
): Promise<DiscoveryAnalysisResponse> => {
|
||||
const res = await api.get<DiscoveryAnalysisResponse>('/analysis/discovery', {
|
||||
params: {
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchAnomalyAnalysis = async (
|
||||
sinceMinutes: number | null = null,
|
||||
limit = 50,
|
||||
): Promise<AnomalyAnalysisResponse> => {
|
||||
const res = await api.get<AnomalyAnalysisResponse>('/analysis/anomalies', {
|
||||
params: {
|
||||
since_minutes: sinceMinutes ?? undefined,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => {
|
||||
const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const deleteRule = async (handle: number, family: string, table: string, chain: string): Promise<void> => {
|
||||
const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, {
|
||||
params: { family, table, chain },
|
||||
});
|
||||
// axios resolves non-2xx as reject; server uses 204 No Content so nothing to return
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* createRuleJson - POST /firewall/rules
|
||||
* Body: CreateRuleRequest (must include expr)
|
||||
*/
|
||||
export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult> => {
|
||||
const res = await api.post<ExecResult>("/firewall/rules", req);
|
||||
const res = await api.post<ExecResult>('/firewall/rules', req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
||||
const res = await api.post<ExecResult>("/firewall/raw", { "cmd": cmd });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Scripts
|
||||
------------------------- */
|
||||
|
||||
export const fetchScriptsStatusAll = async (): Promise<any> => {
|
||||
const res = await api.get("/scripts/scripts/status");
|
||||
const res = await api.post<ExecResult>('/firewall/raw', { cmd });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchScriptStatusForName = async (name: string): Promise<any> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
||||
export const fetchScriptsAll = async (): Promise<ScriptWithStatus[]> => {
|
||||
const res = await api.get<ScriptWithStatus[]>('/scripts/scripts');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const listScripts = async (): Promise<ScriptInfo[]> => {
|
||||
const res = await api.get<ScriptInfo[]>("/scripts/scripts");
|
||||
return res.data;
|
||||
const all = await fetchScriptsAll();
|
||||
return all.map((script) => ({ name: script.name, path: script.path }));
|
||||
};
|
||||
|
||||
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
|
||||
const all = await fetchScriptsAll();
|
||||
const found = all.find((script) => script.name === name);
|
||||
|
||||
if (!found) {
|
||||
return { name, mappings: [] };
|
||||
}
|
||||
|
||||
return { name: found.name, mappings: found.mappings || [] };
|
||||
};
|
||||
|
||||
export const uploadScript = async (opts: {
|
||||
name: string;
|
||||
script: File | Blob;
|
||||
requirements?: File | Blob | null;
|
||||
}): Promise<ScriptInfo> => {
|
||||
}): Promise<ScriptUploadResponse> => {
|
||||
const fd = new FormData();
|
||||
fd.append("name", opts.name);
|
||||
fd.append("script", opts.script);
|
||||
if (opts.requirements) fd.append("requirements", opts.requirements as Blob);
|
||||
fd.append('name', opts.name);
|
||||
fd.append('script', opts.script);
|
||||
|
||||
// axios will set multipart/form-data boundary automatically when FormData passed
|
||||
const res = await api.post<ScriptInfo>("/scripts/scripts", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
if (opts.requirements) {
|
||||
fd.append('requirements', opts.requirements as Blob);
|
||||
}
|
||||
|
||||
const res = await api.post<ScriptUploadResponse>('/scripts/scripts', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const downloadScript = async (name: string): Promise<any> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`);
|
||||
export const downloadScript = async (name: string): Promise<Blob> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
|
||||
return res.data as Blob;
|
||||
};
|
||||
|
||||
export const downloadRequirements = async (name: string): Promise<Blob> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, { responseType: 'blob' });
|
||||
return res.data as Blob;
|
||||
};
|
||||
|
||||
export const uploadRequirements = async (
|
||||
name: string,
|
||||
requirements: File | Blob,
|
||||
): Promise<RequirementsUploadResult> => {
|
||||
const fd = new FormData();
|
||||
fd.append('requirements', requirements);
|
||||
|
||||
const res = await api.put<RequirementsUploadResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const deleteScript = async (name: string, qnum?: number | null): Promise<any> => {
|
||||
export const deleteRequirements = async (name: string): Promise<RequirementsDeleteResult> => {
|
||||
const res = await api.delete<RequirementsDeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
|
||||
const params: Record<string, any> = {};
|
||||
if (typeof qnum !== "undefined") params.qnum = qnum;
|
||||
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||
if (typeof qnum !== 'undefined' && qnum !== null) {
|
||||
params.qnum = qnum;
|
||||
}
|
||||
|
||||
const res = await api.delete<DeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const enableScript = async (name: string, req: EnableRequest): Promise<any> => {
|
||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
|
||||
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const disableScript = async (name: string, qnum: number): Promise<any> => {
|
||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
|
||||
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||
params: { qnum },
|
||||
});
|
||||
return res.data;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/AppRouter.tsx
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import App from './App'; // your layout component (has <Outlet />)
|
||||
|
||||
import App from './App';
|
||||
import Analysis from './pages/Analysis';
|
||||
import { Firewall } from './pages/Firewall';
|
||||
import Home from './pages/Home';
|
||||
import Network from './pages/Network';
|
||||
@@ -8,34 +9,28 @@ import Scripting from './pages/Scripting';
|
||||
import Sniffing from './pages/Sniffing';
|
||||
import { PATHS } from './routes';
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h2>404 - Not Found</h2>
|
||||
<p>The requested page does not exist.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* App is the top-level layout; Outlet renders the active child route */}
|
||||
<Route path={PATHS.ROOT} element={<App />}>
|
||||
{/* When the user hits '/', redirect to '/home' */}
|
||||
<Route index element={<Navigate to={PATHS.HOME} replace />} />
|
||||
|
||||
{/* Child routes - these render inside App's <Outlet /> */}
|
||||
<Route path={PATHS.HOME.slice(1)} element={<Home />} />
|
||||
<Route path={PATHS.NETWORK.slice(1)} element={<Network />} />
|
||||
<Route path={PATHS.ANALYSIS.slice(1)} element={<Analysis />} />
|
||||
<Route path={PATHS.SNIFFING.slice(1)} element={<Sniffing />} />
|
||||
<Route path={PATHS.SCRIPTING.slice(1)} element={<Scripting />} />
|
||||
<Route path={PATHS.FIREWALL.slice(1)} element={<Firewall />} />
|
||||
|
||||
{/* Fallback (renders inside layout too) */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
/** simple 404 rendered inside the layout */
|
||||
function NotFound() {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h2>404 – Not Found</h2>
|
||||
<p>The requested page does not exist.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/components/AddChainModal.tsx
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
@@ -14,11 +13,11 @@ import {
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
message,
|
||||
notification,
|
||||
} from 'antd';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
|
||||
import { CmdResult, ExecResult } from '../types/firewall';
|
||||
import type { CmdResult, ExecResult } from '../types/firewall';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -30,7 +29,12 @@ type TableProp = {
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* onClose may be called with (created?: boolean).
|
||||
* If created === true the parent can decide to refresh and optionally show notifications.
|
||||
* If undefined or false, it's a plain close.
|
||||
*/
|
||||
onClose?: (created?: boolean) => void;
|
||||
table?: TableProp; // if provided, family & tableName are prefilled & readonly
|
||||
startOnPreview?: boolean;
|
||||
onSuccess?: () => void;
|
||||
@@ -60,13 +64,12 @@ export default function FirewallAddChainModal({
|
||||
const [results, setResults] = useState<CmdResult[]>([]);
|
||||
const [selectedFamily, setSelectedFamily] = useState<string>(table?.family ?? 'bridge');
|
||||
|
||||
// set defaults and prefill when modal opens or table prop changes
|
||||
const isPrefilled = Boolean(table?.family && table?.name);
|
||||
|
||||
useEffect(() => {
|
||||
// only prefill family/tableName if provided by prop
|
||||
form.setFieldsValue({
|
||||
family: table?.family ?? 'bridge',
|
||||
tableName: table?.name ?? 'filter',
|
||||
// intentionally do NOT set chainName here so user input is preserved normally
|
||||
type: 'filter',
|
||||
hook: 'forward',
|
||||
priority: 0,
|
||||
@@ -94,43 +97,40 @@ export default function FirewallAddChainModal({
|
||||
setRulesetEmpty(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// don't block UI — warn user
|
||||
message.warning('Could not load ruleset: ' + (err?.message ?? String(err)));
|
||||
notification.warning({
|
||||
message: 'Could not load ruleset',
|
||||
description: err?.message ?? String(err),
|
||||
duration: 6,
|
||||
});
|
||||
setRulesetEmpty(false);
|
||||
} finally {
|
||||
setLoadingRuleset(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Build add chain command — now reads chainName from values OR live form value
|
||||
function buildCommands(values: any): string[] {
|
||||
const family = values.family ?? form.getFieldValue('family') ?? table?.family ?? 'bridge';
|
||||
const tableName = values.tableName ?? form.getFieldValue('tableName') ?? table?.name ?? 'filter';
|
||||
function buildCommands(values?: any): string[] {
|
||||
const vals = values ?? form.getFieldsValue();
|
||||
|
||||
// prefer explicitly provided values.chainName, otherwise read from form live state
|
||||
const chainFromValues =
|
||||
values && typeof values.chainName === 'string' && values.chainName.trim().length > 0
|
||||
? values.chainName.trim()
|
||||
: undefined;
|
||||
const chainFromForm = form.getFieldValue('chainName');
|
||||
const chain =
|
||||
chainFromValues ||
|
||||
(typeof chainFromForm === 'string' && chainFromForm.trim().length > 0 ? chainFromForm.trim() : undefined) ||
|
||||
'mychain';
|
||||
const family = (vals.family ?? table?.family ?? 'bridge').trim();
|
||||
const tableName = (vals.tableName ?? table?.name ?? 'filter').trim();
|
||||
|
||||
const type = values.type ?? form.getFieldValue('type') ?? 'filter';
|
||||
const hook = values.hook ?? form.getFieldValue('hook') ?? 'forward';
|
||||
const type = (vals.type ?? 'filter').trim();
|
||||
const hook = (vals.hook ?? 'forward').trim();
|
||||
const priority =
|
||||
typeof (values.priority ?? form.getFieldValue('priority')) === 'number'
|
||||
? (values.priority ?? form.getFieldValue('priority'))
|
||||
typeof vals.priority === 'number'
|
||||
? vals.priority
|
||||
: Number.isFinite(Number(vals.priority))
|
||||
? Number(vals.priority)
|
||||
: 0;
|
||||
const policy = values.policy ?? form.getFieldValue('policy') ?? '';
|
||||
const policy = vals.policy ?? '';
|
||||
|
||||
const chain = hook;
|
||||
|
||||
const policyPart = policy ? ` policy ${policy} ;` : '';
|
||||
const cmd = `add chain ${family} ${tableName} ${chain} { type ${type} hook ${hook} priority ${priority} ;${policyPart} }`;
|
||||
return [cmd];
|
||||
}
|
||||
|
||||
// Execute commands sequentially
|
||||
async function executeCommands(cmds: string[]) {
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
@@ -150,20 +150,27 @@ export default function FirewallAddChainModal({
|
||||
setResults(acc);
|
||||
setRunning(false);
|
||||
|
||||
// refresh ruleset after running
|
||||
try {
|
||||
await refreshRuleset();
|
||||
} catch {
|
||||
/* ignore - refreshRuleset handles messaging */
|
||||
}
|
||||
|
||||
const hadError = acc.some((r) => r.err);
|
||||
if (!hadError) {
|
||||
message.success('Chain created and ruleset refreshed');
|
||||
onClose();
|
||||
notification.success({
|
||||
message: 'Chain created',
|
||||
description: 'Chain created and ruleset refreshed locally in the modal.',
|
||||
duration: 4,
|
||||
});
|
||||
|
||||
onClose?.(true);
|
||||
if (onSuccess) onSuccess();
|
||||
} else {
|
||||
message.error('Some commands returned errors — see results in the modal');
|
||||
notification.error({
|
||||
message: 'Some commands returned errors',
|
||||
description: 'See execution results below for details.',
|
||||
duration: 6,
|
||||
});
|
||||
setStep(1);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +180,7 @@ export default function FirewallAddChainModal({
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (step === 0) onClose();
|
||||
if (step === 0) onClose?.(false);
|
||||
else setStep(0);
|
||||
}}
|
||||
>
|
||||
@@ -184,22 +191,28 @@ export default function FirewallAddChainModal({
|
||||
<Space>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const txt = buildCommands(form.getFieldsValue()).join('\n');
|
||||
navigator.clipboard.writeText(txt).then(() => message.success('Command copied'));
|
||||
try {
|
||||
await navigator.clipboard.writeText(txt);
|
||||
notification.success({ message: 'Command copied to clipboard' });
|
||||
} catch {
|
||||
notification.warning({ message: 'Unable to copy to clipboard' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy Command
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
// validate required fields before going to preview
|
||||
try {
|
||||
await form.validateFields(['chainName', ...(table ? [] : ['tableName', 'family'])]);
|
||||
const requiredFields = ['chainName'];
|
||||
if (!isPrefilled) requiredFields.push('tableName', 'family');
|
||||
await form.validateFields(requiredFields as any);
|
||||
setStep(1);
|
||||
} catch (e) {
|
||||
// validation errors shown by AntD
|
||||
} catch {
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -221,7 +234,7 @@ export default function FirewallAddChainModal({
|
||||
|
||||
if (loadingRuleset) {
|
||||
return (
|
||||
<Modal title="Add Chain" open={open} onCancel={onClose} footer={null} width={800}>
|
||||
<Modal title="Add Chain" open={open} onCancel={() => onClose?.(false)} footer={null} width={800} destroyOnClose>
|
||||
<div style={{ textAlign: 'center', padding: 28 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
@@ -229,11 +242,16 @@ export default function FirewallAddChainModal({
|
||||
);
|
||||
}
|
||||
|
||||
const isPrefilled = Boolean(table?.family && table?.name);
|
||||
|
||||
return (
|
||||
<Modal title="Add Chain" open={open} onCancel={onClose} width={800} footer={renderFooter()} destroyOnClose>
|
||||
{/* Step 0: Form */}
|
||||
<Modal
|
||||
title="Add Chain"
|
||||
open={open}
|
||||
onCancel={() => onClose?.(false)}
|
||||
width={800}
|
||||
footer={renderFooter()}
|
||||
destroyOnClose
|
||||
>
|
||||
|
||||
{step === 0 && (
|
||||
<div>
|
||||
{rulesetEmpty && (
|
||||
@@ -253,7 +271,7 @@ export default function FirewallAddChainModal({
|
||||
}}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
{/* family & table: show inputs only when not provided via props */}
|
||||
|
||||
<Col span={8}>
|
||||
{isPrefilled ? (
|
||||
<Form.Item label="Family">
|
||||
@@ -287,12 +305,6 @@ export default function FirewallAddChainModal({
|
||||
</Form.Item>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Form.Item name="chainName" label="Chain Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="e.g. forward" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={12} style={{ marginTop: 8 }}>
|
||||
@@ -338,7 +350,7 @@ export default function FirewallAddChainModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Preview & Results */}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<Card title="Command preview" style={{ marginBottom: 12 }}>
|
||||
@@ -352,9 +364,14 @@ export default function FirewallAddChainModal({
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const txt = buildCommands(form.getFieldsValue()).join('\n');
|
||||
navigator.clipboard.writeText(txt).then(() => message.success('Command copied'));
|
||||
try {
|
||||
await navigator.clipboard.writeText(txt);
|
||||
notification.success({ message: 'Command copied to clipboard' });
|
||||
} catch {
|
||||
notification.warning({ message: 'Unable to copy to clipboard' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy command
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// src/components/FirewallManager.tsx
|
||||
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, message } from 'antd';
|
||||
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, notification } from 'antd';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
|
||||
|
||||
@@ -20,7 +19,7 @@ type CmdResult = {
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onClose?: (created?: boolean) => void; // created === true when a table was created
|
||||
startOnPreview?: boolean;
|
||||
};
|
||||
|
||||
@@ -42,7 +41,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
const [results, setResults] = useState<CmdResult[]>([]);
|
||||
const [selectedFamily, setSelectedFamily] = useState<string>('bridge');
|
||||
|
||||
// initialize and refresh when modal opens
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
family: 'bridge',
|
||||
@@ -70,20 +68,23 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
setRulesetEmpty(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.warning('Could not load ruleset: ' + (err?.message ?? String(err)));
|
||||
notification.warning({
|
||||
message: 'Could not load ruleset',
|
||||
description: err?.message ?? String(err),
|
||||
duration: 6,
|
||||
});
|
||||
setRulesetEmpty(false);
|
||||
} finally {
|
||||
setLocalLoadingRuleset(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Build commands: only create table
|
||||
function buildCommands(values: any): string[] {
|
||||
const family = values.family ?? 'bridge';
|
||||
const table = values.tableName ?? 'filter';
|
||||
const family = (values.family ?? 'bridge').trim();
|
||||
const table = (values.tableName ?? 'filter').trim();
|
||||
return [`add table ${family} ${table}`];
|
||||
}
|
||||
|
||||
// Execute commands sequentially
|
||||
async function executeCommands(cmds: string[]) {
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
@@ -103,19 +104,25 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
setResults(acc);
|
||||
setRunning(false);
|
||||
|
||||
// refresh ruleset after running
|
||||
try {
|
||||
await refreshRuleset();
|
||||
} catch {
|
||||
// ignore — refreshRuleset handles messaging
|
||||
}
|
||||
|
||||
const hadError = acc.some((r) => r.err);
|
||||
if (!hadError) {
|
||||
message.success('Table created and ruleset refreshed');
|
||||
onClose();
|
||||
notification.success({
|
||||
message: 'Table created',
|
||||
description: 'Table was created and ruleset has been refreshed locally in the modal.',
|
||||
duration: 4,
|
||||
});
|
||||
onClose?.(true); // signal parent to refresh and close modal
|
||||
} else {
|
||||
message.error('Some commands returned errors — see results in the modal');
|
||||
notification.error({
|
||||
message: 'Some commands returned errors',
|
||||
description: 'See execution results below for details.',
|
||||
duration: 6,
|
||||
});
|
||||
setStep(1);
|
||||
}
|
||||
}
|
||||
@@ -125,7 +132,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (step === 0) onClose();
|
||||
if (step === 0) onClose?.(false);
|
||||
else setStep(0);
|
||||
}}
|
||||
>
|
||||
@@ -134,7 +141,20 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
|
||||
{step === 0 ? (
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => setStep(1)}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form
|
||||
.validateFields()
|
||||
.then(() => setStep(1))
|
||||
.catch(() =>
|
||||
notification.warning({
|
||||
message: 'Validation',
|
||||
description: 'Please fill required fields before preview.',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -151,10 +171,9 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
);
|
||||
}
|
||||
|
||||
// show spinner while checking ruleset
|
||||
if (localLoadingRuleset) {
|
||||
return (
|
||||
<Modal title="Create Table" open={open} onCancel={onClose} footer={null} width={700}>
|
||||
<Modal title="Create Table" open={open} onCancel={() => onClose?.(false)} footer={null} width={700}>
|
||||
<div style={{ textAlign: 'center', padding: 28 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
@@ -163,8 +182,15 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Create Table" open={open} onCancel={onClose} width={700} footer={renderFooter()} destroyOnClose>
|
||||
{/* Step 0: minimal form */}
|
||||
<Modal
|
||||
title="Create Table"
|
||||
open={open}
|
||||
onCancel={() => onClose?.(false)}
|
||||
width={700}
|
||||
footer={renderFooter()}
|
||||
destroyOnClose
|
||||
>
|
||||
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<Form
|
||||
@@ -201,7 +227,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: preview + results */}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<Card title="Command preview" style={{ marginBottom: 12 }}>
|
||||
|
||||
@@ -1,122 +1,109 @@
|
||||
import { Alert, Button, Card, Modal, Space, Spin, Table, Typography, message } from 'antd';
|
||||
import { ArrowDownOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Divider, Modal, notification, Space, Spin, Table, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
|
||||
import { CmdResult, ExecResult } from '../types/firewall';
|
||||
import { ReactElement, useCallback, useMemo, useState } from 'react';
|
||||
import { execFirewallRaw } from '../api/apiClient';
|
||||
import type { CmdResult, ExecResult, RuleOut, TableOut } from '../types/firewall';
|
||||
import FirewallAddChainModal from './FireWallAddChainModal';
|
||||
import FirewallAddTableModal from './FireWallAddTableModal';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type NFTRule = {
|
||||
handle?: number | string;
|
||||
expr?: any;
|
||||
[k: string]: any;
|
||||
};
|
||||
|
||||
type NFTChain = {
|
||||
name: string;
|
||||
type?: string | null;
|
||||
hook?: string | null;
|
||||
priority?: number | null;
|
||||
policy?: string | null;
|
||||
rules: NFTRule[];
|
||||
};
|
||||
function renderRuleFriendly(rule: RuleOut | any): string {
|
||||
if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text;
|
||||
if (rule?.expr && typeof rule.expr === 'string') return rule.expr;
|
||||
|
||||
type NFTTable = {
|
||||
family?: string | null;
|
||||
name: string;
|
||||
chains: NFTChain[];
|
||||
};
|
||||
|
||||
/* -------------------------
|
||||
Extract NFT structure
|
||||
------------------------- */
|
||||
function extractTablesFromParsed(parsed: any): NFTTable[] {
|
||||
if (!parsed) return [];
|
||||
|
||||
if (Array.isArray(parsed.nftables)) {
|
||||
const tablesMap = new Map<string, NFTTable>();
|
||||
const chainsMap = new Map<string, NFTChain>();
|
||||
|
||||
for (const item of parsed.nftables) {
|
||||
if (item.table) {
|
||||
const t = item.table;
|
||||
const key = `${t.family ?? 'n/a'}:${t.name}`;
|
||||
if (!tablesMap.has(key)) {
|
||||
tablesMap.set(key, { family: t.family ?? null, name: t.name, chains: [] });
|
||||
const expr = rule?.expr ?? rule;
|
||||
if (Array.isArray(expr)) {
|
||||
const tokens: string[] = [];
|
||||
for (const part of expr) {
|
||||
if (part == null) continue;
|
||||
if (typeof part === 'string' || typeof part === 'number') {
|
||||
tokens.push(String(part));
|
||||
continue;
|
||||
}
|
||||
} else if (item.chain) {
|
||||
const c = item.chain;
|
||||
const fam = c.family ?? 'n/a';
|
||||
const table = c.table ?? 'n/a';
|
||||
const tableKey = `${fam}:${table}`;
|
||||
|
||||
if (!tablesMap.has(tableKey)) {
|
||||
tablesMap.set(tableKey, { family: c.family ?? null, name: table, chains: [] });
|
||||
}
|
||||
|
||||
const chainKey = `${fam}:${table}:${c.name}`;
|
||||
const chainObj: NFTChain = {
|
||||
name: c.name,
|
||||
type: c.type ?? null,
|
||||
hook: c.hook ?? null,
|
||||
priority: (c.priority as number) ?? null,
|
||||
policy: c.policy ?? null,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
chainsMap.set(chainKey, chainObj);
|
||||
tablesMap.get(tableKey)!.chains.push(chainObj);
|
||||
} else if (item.rule) {
|
||||
const r = item.rule;
|
||||
const fam = r.family ?? r.table?.family ?? 'n/a';
|
||||
const table = r.table ?? r.table?.name ?? r.table_name ?? 'n/a';
|
||||
const chainName = r.chain ?? r.chain?.name ?? 'unknown';
|
||||
|
||||
const tableKey = `${fam}:${table}`;
|
||||
const chainKey = `${fam}:${table}:${chainName}`;
|
||||
|
||||
if (!tablesMap.has(tableKey)) {
|
||||
tablesMap.set(tableKey, { family: fam ?? null, name: table, chains: [] });
|
||||
}
|
||||
|
||||
if (!chainsMap.has(chainKey)) {
|
||||
const newChain: NFTChain = { name: chainName, rules: [] };
|
||||
chainsMap.set(chainKey, newChain);
|
||||
tablesMap.get(tableKey)!.chains.push(newChain);
|
||||
}
|
||||
|
||||
tablesMap
|
||||
.get(tableKey)!
|
||||
.chains.find((c) => c.name === chainName)!
|
||||
.rules.push({ ...r });
|
||||
if (typeof part === 'object') {
|
||||
if ('match' in part) {
|
||||
const m = (part as any).match;
|
||||
const left = m?.left;
|
||||
const right = m?.right;
|
||||
if (left && left.payload && (typeof right === 'string' || typeof right === 'number')) {
|
||||
const p = left.payload;
|
||||
const prot = p.protocol;
|
||||
const field = p.field;
|
||||
if (prot && field) {
|
||||
tokens.push(`${prot} ${field} ${right}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tablesMap.values());
|
||||
tokens.push('match');
|
||||
continue;
|
||||
}
|
||||
if ('payload' in part) {
|
||||
const p = (part as any).payload;
|
||||
if (p?.protocol && p?.field) {
|
||||
tokens.push(`payload(${p.protocol}.${p.field})`);
|
||||
continue;
|
||||
}
|
||||
tokens.push('payload');
|
||||
continue;
|
||||
}
|
||||
if ('tcp' in part) {
|
||||
const v = (part as any).tcp;
|
||||
if (v && v.dport) tokens.push(`tcp dport ${v.dport}`);
|
||||
else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`);
|
||||
else tokens.push('tcp');
|
||||
continue;
|
||||
}
|
||||
if ('udp' in part) {
|
||||
const v = (part as any).udp;
|
||||
if (v && v.dport) tokens.push(`udp dport ${v.dport}`);
|
||||
else if (v && v.sport) tokens.push(`udp sport ${v.sport}`);
|
||||
else tokens.push('udp');
|
||||
continue;
|
||||
}
|
||||
if ('drop' in part) {
|
||||
tokens.push('drop');
|
||||
continue;
|
||||
}
|
||||
if ('accept' in part) {
|
||||
tokens.push('accept');
|
||||
continue;
|
||||
}
|
||||
if ('counter' in part) {
|
||||
tokens.push('counter');
|
||||
continue;
|
||||
}
|
||||
if ('queue' in part) {
|
||||
const q = (part as any).queue;
|
||||
let tok = 'queue';
|
||||
if (typeof q === 'object' && q !== null) {
|
||||
const num = q.num ?? q.number ?? q.queue_number ?? q.from ?? q.range;
|
||||
if (num !== undefined) tok += ` num ${num}`;
|
||||
if (q.bypass) tok += ' bypass';
|
||||
} else if (typeof q === 'number') {
|
||||
tok += ` num ${q}`;
|
||||
} else if (typeof q === 'string') {
|
||||
tok += ` num ${q}`;
|
||||
}
|
||||
tokens.push(tok);
|
||||
continue;
|
||||
}
|
||||
tokens.push(Object.keys(part).sort().join('+'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (tokens.length > 0) return tokens.join(' ');
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.tables)) {
|
||||
return parsed.tables.map((t: any) => ({
|
||||
family: t.family ?? null,
|
||||
name: t.name,
|
||||
chains: t.chains ?? [],
|
||||
}));
|
||||
if (rule?.expr && typeof rule.expr === 'object') {
|
||||
try {
|
||||
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
|
||||
} catch {
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Rule preview
|
||||
------------------------- */
|
||||
function rulePreview(rule: NFTRule): string {
|
||||
if (typeof rule === 'string') return rule;
|
||||
if (rule.expr && typeof rule.expr === 'string') return rule.expr;
|
||||
if (rule.rule && typeof rule.rule === 'string') return rule.rule;
|
||||
if (rule.handle && Object.keys(rule).length === 1) return `handle ${rule.handle}`;
|
||||
|
||||
try {
|
||||
return JSON.stringify(rule, null, 2);
|
||||
} catch {
|
||||
@@ -124,80 +111,48 @@ function rulePreview(rule: NFTRule): string {
|
||||
}
|
||||
}
|
||||
|
||||
/* Helper: determine success by rc
|
||||
Some endpoints return rc === -1 on success in your environment,
|
||||
so treat rc === 0 or rc === -1 as success. */
|
||||
/* Helper: success RC */
|
||||
function isSuccessRc(out?: ExecResult | null): boolean {
|
||||
if (!out) return false;
|
||||
return out.rc === 0 || out.rc === -1;
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Component
|
||||
------------------------- */
|
||||
export default function FirewallTables(): ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tables, setTables] = useState<NFTTable[]>([]);
|
||||
const [results, setResults] = useState<CmdResult[]>([]);
|
||||
type Props = {
|
||||
tables: TableOut[]; // passed from parent
|
||||
error?: Error | null;
|
||||
refreshRules: () => Promise<void>; // trigger to re-fetch ruleset
|
||||
};
|
||||
|
||||
|
||||
export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
|
||||
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
|
||||
|
||||
async function loadRuleset() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchRuleset();
|
||||
const parsed = extractTablesFromParsed((res as any).ruleset);
|
||||
setTables(parsed);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRuleset();
|
||||
}, []);
|
||||
|
||||
// helper to run multiple commands sequentially and collect results
|
||||
async function runCommands(cmds: string[]) {
|
||||
const runCommands = useCallback(async (cmds: string[]) => {
|
||||
const acc: CmdResult[] = [];
|
||||
for (const cmd of cmds) {
|
||||
try {
|
||||
const out = (await execFirewallRaw(cmd)) as ExecResult;
|
||||
if (isSuccessRc(out)) {
|
||||
acc.push({ cmd, out });
|
||||
} else {
|
||||
acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' });
|
||||
}
|
||||
if (isSuccessRc(out)) acc.push({ cmd, out });
|
||||
else acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' });
|
||||
} catch (err: any) {
|
||||
acc.push({ cmd, err: err?.message ?? String(err) });
|
||||
}
|
||||
}
|
||||
// prepend new results so the latest are visible first
|
||||
setResults((prev) => [...acc, ...prev]);
|
||||
return acc;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// delete a single rule
|
||||
async function handleDeleteRule(
|
||||
family: string | null | undefined,
|
||||
table: string,
|
||||
chain: string,
|
||||
handle: number | string,
|
||||
) {
|
||||
const handleDeleteRule = useCallback(
|
||||
async (family: string | null | undefined, table: string, chain: string, handle: number | string) => {
|
||||
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
|
||||
|
||||
Modal.confirm({
|
||||
title: 'Delete Rule',
|
||||
content: (
|
||||
<>
|
||||
<Paragraph>Are you sure you want to delete this rule?</Paragraph>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
|
||||
<Divider />
|
||||
<Paragraph copyable>{cmd}</Paragraph>
|
||||
</>
|
||||
),
|
||||
onOk: async () => {
|
||||
@@ -205,23 +160,27 @@ export default function FirewallTables(): ReactElement {
|
||||
const res = await runCommands([cmd]);
|
||||
const first = res[0];
|
||||
if (!first.err) {
|
||||
message.success('Rule deleted');
|
||||
notification.success({ message: 'Rule deleted', description: cmd });
|
||||
} else {
|
||||
message.error('Delete returned error — check results panel');
|
||||
notification.error({ message: 'Delete returned error', description: first.err });
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error('Delete failed: ' + (err?.message ?? String(err)));
|
||||
notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
|
||||
} finally {
|
||||
await loadRuleset();
|
||||
try {
|
||||
await refresh();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[runCommands, refresh],
|
||||
);
|
||||
|
||||
// delete a chain (non-force)
|
||||
async function handleDeleteChain(family: string | null | undefined, table: string, chain: string) {
|
||||
const handleDeleteChain = useCallback(
|
||||
async (family: string | null | undefined, table: string, chain: string) => {
|
||||
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
|
||||
|
||||
Modal.confirm({
|
||||
title: 'Delete Chain',
|
||||
content: (
|
||||
@@ -229,7 +188,8 @@ export default function FirewallTables(): ReactElement {
|
||||
<Paragraph>
|
||||
This will delete the chain <i>{chain}</i> in table <i>{table}</i> unrevertably.
|
||||
</Paragraph>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
|
||||
<Divider />
|
||||
<Paragraph copyable>{cmd}</Paragraph>
|
||||
</>
|
||||
),
|
||||
onOk: async () => {
|
||||
@@ -237,23 +197,27 @@ export default function FirewallTables(): ReactElement {
|
||||
const res = await runCommands([cmd]);
|
||||
const first = res[0];
|
||||
if (!first.err) {
|
||||
message.success(`Chain ${chain} deleted`);
|
||||
notification.success({ message: `Chain ${chain} deleted`, description: cmd });
|
||||
} else {
|
||||
message.error(`Chain deletion returned error — check results panel`);
|
||||
notification.error({ message: 'Chain deletion returned error', description: first.err });
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error('Chain deletion failed: ' + (err?.message ?? String(err)));
|
||||
notification.error({ message: 'Chain deletion failed', description: err?.message ?? String(err) });
|
||||
} finally {
|
||||
await loadRuleset();
|
||||
try {
|
||||
await refresh();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[runCommands, refresh],
|
||||
);
|
||||
|
||||
// delete a table (non-force)
|
||||
async function handleDeleteTable(family: string | null | undefined, table: string) {
|
||||
const handleDeleteTable = useCallback(
|
||||
async (family: string | null | undefined, table: string) => {
|
||||
const cmd = `delete table ${family ?? 'inet'} ${table}`;
|
||||
|
||||
Modal.confirm({
|
||||
title: 'Delete Table',
|
||||
content: (
|
||||
@@ -261,7 +225,8 @@ export default function FirewallTables(): ReactElement {
|
||||
<Paragraph>
|
||||
This will delete the table <i>{table}</i> including all its chains and rules unrevertably.
|
||||
</Paragraph>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
|
||||
<Divider />
|
||||
<Paragraph copyable>{cmd}</Paragraph>
|
||||
</>
|
||||
),
|
||||
onOk: async () => {
|
||||
@@ -269,60 +234,82 @@ export default function FirewallTables(): ReactElement {
|
||||
const res = await runCommands([cmd]);
|
||||
const first = res[0];
|
||||
if (!first.err) {
|
||||
message.success(`Table ${table} deleted`);
|
||||
notification.success({ message: `Table ${table} deleted`, description: cmd });
|
||||
} else {
|
||||
message.error(`Table deletion returned error — check results panel`);
|
||||
notification.error({ message: 'Table deletion returned error', description: first.err });
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error('Table deletion failed: ' + (err?.message ?? String(err)));
|
||||
notification.error({ message: 'Table deletion failed', description: err?.message ?? String(err) });
|
||||
} finally {
|
||||
await loadRuleset();
|
||||
try {
|
||||
await refresh();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[runCommands, refresh],
|
||||
);
|
||||
|
||||
// Refresh handler
|
||||
async function handleRefresh() {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadRuleset();
|
||||
message.success('Ruleset refreshed');
|
||||
} catch {
|
||||
// loadRuleset sets error state
|
||||
await refresh();
|
||||
notification.success({ message: 'Ruleset refreshed' });
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Refresh failed', description: err?.message ?? String(err) });
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
if (loading) return <Spin size="large" style={{ display: 'block', margin: '40px auto' }} />;
|
||||
const tablesToRender = useMemo(() => tables ?? [], [tables]);
|
||||
|
||||
if (error) return <Alert type="error" message="Failed to load firewall rules" description={error} />;
|
||||
if (error)
|
||||
return <Alert type="error" message="Failed to load firewall rules" description={error.message ?? String(error)} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FirewallAddTableModal open={isOpenTableCreatorModal} onClose={() => setIsOpenTableCreatorModal(false)} />
|
||||
|
||||
<FirewallAddTableModal
|
||||
open={isOpenTableCreatorModal}
|
||||
onClose={(created?: boolean) => {
|
||||
setIsOpenTableCreatorModal(false);
|
||||
if (created) {
|
||||
void refresh().catch(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title="Firewall Tables"
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={handleRefresh} loading={refreshing}>
|
||||
Refresh
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
icon={refreshing ? <Spin size="small" /> : <ReloadOutlined />}
|
||||
>
|
||||
Refresh Ruleset
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setIsOpenTableCreatorModal(true)}>
|
||||
Add Table
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpenTableCreatorModal(true)}>Add Table</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{tables.length === 0 && (
|
||||
{tablesToRender.length === 0 && (
|
||||
<Alert
|
||||
type="info"
|
||||
title="No firewall tables found"
|
||||
message="No firewall tables found"
|
||||
description="You can create a new table using the button above."
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{tables.map((table) => {
|
||||
const totalRules = table.chains.reduce((acc, c) => acc + c.rules.length, 0);
|
||||
|
||||
{tablesToRender.map((table) => {
|
||||
const totalRules = table.chains.reduce((acc, c) => acc + (c.rules?.length ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -333,7 +320,7 @@ export default function FirewallTables(): ReactElement {
|
||||
<div style={{ display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Table "{table.name}"
|
||||
Table {table.name}
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
<b>Family:</b> {table.family ?? 'unknown'} <b>Chains:</b> {table.chains.length} {' '}
|
||||
@@ -342,19 +329,30 @@ export default function FirewallTables(): ReactElement {
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button danger size="small" onClick={() => handleDeleteTable(table.family, table.name)}>
|
||||
Delete Table
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteTable(table.family, table.name)}
|
||||
/>
|
||||
<Button type="primary" onClick={() => setIsOpenChainCreatorModal(true)}>
|
||||
Add Chain
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
extra={<Button onClick={() => setIsOpenChainCreatorModal(true)}>Add Chain</Button>}
|
||||
>
|
||||
|
||||
<FirewallAddChainModal
|
||||
open={isOpenChainCreatorModal}
|
||||
onClose={() => setIsOpenChainCreatorModal(false)}
|
||||
table={{ family: table.family, name: table.name }}
|
||||
onClose={(created?: boolean) => {
|
||||
setIsOpenChainCreatorModal(false);
|
||||
if (created) {
|
||||
void refresh().catch(() => {});
|
||||
}
|
||||
}}
|
||||
table={{ family: table.family ?? '', name: table.name }}
|
||||
/>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{table.chains.map((chain) => {
|
||||
const columns: ColumnsType<any> = [
|
||||
@@ -362,41 +360,40 @@ export default function FirewallTables(): ReactElement {
|
||||
{
|
||||
title: 'Handle',
|
||||
dataIndex: 'handle',
|
||||
width: 80,
|
||||
render: (v) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: 'Rule Content',
|
||||
dataIndex: 'raw',
|
||||
render: (v) => <Paragraph copyable>{v}</Paragraph>,
|
||||
title: 'Rule',
|
||||
dataIndex: 'frontendParsed',
|
||||
render: (v) => <Paragraph ellipsis={{ rows: 2, expandable: false }}>{v}</Paragraph>,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
dataIndex: 'actions',
|
||||
width: 80,
|
||||
render: (_: any, rec: any) =>
|
||||
rec.handle ? (
|
||||
<Space>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
|
||||
>
|
||||
Delete Rule
|
||||
</Button>
|
||||
</Space>
|
||||
/>
|
||||
) : (
|
||||
<Space>
|
||||
<Button size="small" disabled>
|
||||
Delete Rule
|
||||
</Button>
|
||||
<Button size="small" disabled icon={<ArrowDownOutlined />} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = chain.rules.map((r, idx) => ({
|
||||
key: idx,
|
||||
const dataSource = (chain.rules ?? []).map((r: RuleOut, idx: number) => ({
|
||||
key: `${chain.name}:${idx}`,
|
||||
idx: idx + 1,
|
||||
handle: r.handle ?? null,
|
||||
raw: rulePreview(r),
|
||||
frontendParsed: renderRuleFriendly(r),
|
||||
backendtext: r.text,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -423,11 +420,10 @@ export default function FirewallTables(): ReactElement {
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
|
||||
>
|
||||
Delete Chain
|
||||
</Button>
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
|
||||
597
frontend/src/components/PacketInspectModal.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import { Button, Descriptions, Modal, Space, Tabs, Typography } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { PacketRow } from '../types/packets';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ARP_OPCODE_LABELS: Record<number, string> = {
|
||||
0: 'Reserved',
|
||||
1: 'REQUEST',
|
||||
2: 'REPLY',
|
||||
3: 'request Reverse',
|
||||
4: 'reply Reverse',
|
||||
5: 'DRARP-Request',
|
||||
6: 'DRARP-Reply',
|
||||
7: 'DRARP-Error',
|
||||
8: 'InARP-Request',
|
||||
9: 'InARP-Reply',
|
||||
10: 'ARP-NAK',
|
||||
11: 'MARS-Request',
|
||||
12: 'MARS-Multi',
|
||||
13: 'MARS-MServ',
|
||||
14: 'MARS-Join',
|
||||
15: 'MARS-Leave',
|
||||
16: 'MARS-NAK',
|
||||
17: 'MARS-Unserv',
|
||||
18: 'MARS-SJoin',
|
||||
19: 'MARS-SLeave',
|
||||
20: 'MARS-Grouplist-Request',
|
||||
21: 'MARS-Grouplist-Reply',
|
||||
22: 'MARS-Redirect-Map',
|
||||
23: 'MAPOS-UNARP',
|
||||
24: 'OP_EXP1',
|
||||
25: 'OP_EXP2',
|
||||
};
|
||||
|
||||
const ICMP_TYPE_LABELS: Record<number, string> = {
|
||||
0: 'Echo Reply',
|
||||
3: 'Destination Unreachable',
|
||||
4: 'Source Quench (Deprecated)',
|
||||
5: 'Redirect',
|
||||
6: 'Alternate Host Address (Deprecated)',
|
||||
8: 'Echo',
|
||||
9: 'Router Advertisement',
|
||||
10: 'Router Solicitation',
|
||||
11: 'Time Exceeded',
|
||||
12: 'Parameter Problem',
|
||||
13: 'Timestamp',
|
||||
14: 'Timestamp Reply',
|
||||
15: 'Information Request (Deprecated)',
|
||||
16: 'Information Reply (Deprecated)',
|
||||
17: 'Address Mask Request (Deprecated)',
|
||||
18: 'Address Mask Reply (Deprecated)',
|
||||
19: 'Reserved (for Security)',
|
||||
30: 'Traceroute (Deprecated)',
|
||||
31: 'Datagram Conversion Error (Deprecated)',
|
||||
32: 'Mobile Host Redirect (Deprecated)',
|
||||
33: 'IPv6 Where-Are-You (Deprecated)',
|
||||
34: 'IPv6 I-Am-Here (Deprecated)',
|
||||
35: 'Mobile Registration Request (Deprecated)',
|
||||
36: 'Mobile Registration Reply (Deprecated)',
|
||||
37: 'Domain Name Request (Deprecated)',
|
||||
38: 'Domain Name Reply (Deprecated)',
|
||||
39: 'SKIP (Deprecated)',
|
||||
40: 'Photuris',
|
||||
41: 'ICMP experimental mobility',
|
||||
42: 'Extended Echo Request',
|
||||
43: 'Extended Echo Reply',
|
||||
253: 'RFC3692-style Experiment 1',
|
||||
254: 'RFC3692-style Experiment 2',
|
||||
255: 'Reserved',
|
||||
};
|
||||
|
||||
const DNS_QUERY_TYPE_LABELS: Record<number, string> = {
|
||||
1: 'A',
|
||||
2: 'NS',
|
||||
5: 'CNAME',
|
||||
6: 'SOA',
|
||||
12: 'PTR',
|
||||
15: 'MX',
|
||||
16: 'TXT',
|
||||
28: 'AAAA',
|
||||
33: 'SRV',
|
||||
41: 'OPT',
|
||||
43: 'DS',
|
||||
46: 'RRSIG',
|
||||
47: 'NSEC',
|
||||
48: 'DNSKEY',
|
||||
50: 'NSEC3',
|
||||
51: 'NSEC3PARAM',
|
||||
52: 'TLSA',
|
||||
59: 'CDS',
|
||||
60: 'CDNSKEY',
|
||||
61: 'OPENPGPKEY',
|
||||
62: 'CSYNC',
|
||||
64: 'SVCB',
|
||||
65: 'HTTPS',
|
||||
255: 'ANY',
|
||||
257: 'CAA',
|
||||
};
|
||||
|
||||
function base64ToHex(b64: string) {
|
||||
try {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
|
||||
return Array.from(bytes)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
} catch {
|
||||
return '(invalid base64)';
|
||||
}
|
||||
}
|
||||
|
||||
function base64ToBlob(b64: string) {
|
||||
const bin = atob(b64);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) arr[i] = bin.charCodeAt(i);
|
||||
return new Blob([arr.buffer], { type: 'application/octet-stream' });
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string) {
|
||||
const bin = atob(b64);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
if (value == null) return '(no tshark data)';
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '(failed to format tshark data)';
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function buildReducedMetadata(packet: PacketRow | null) {
|
||||
if (!packet) return null;
|
||||
const dpi = isPlainObject(packet.dpi_metadata) ? { ...packet.dpi_metadata } : null;
|
||||
if (dpi && 'layers' in dpi) delete dpi.layers;
|
||||
return {
|
||||
dpi_metadata: dpi,
|
||||
capture_metadata: packet.capture_metadata ?? null,
|
||||
telemetry_metadata: packet.telemetry_metadata ?? null,
|
||||
capture_observations: packet.capture_observations ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function getDpiDict(packet: PacketRow, key: string): Record<string, unknown> | null {
|
||||
const dpi = packet.dpi_metadata;
|
||||
if (!dpi || typeof dpi !== 'object' || Array.isArray(dpi)) return null;
|
||||
const value = dpi[key];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringValue(value: unknown) {
|
||||
return value == null ? null : String(value);
|
||||
}
|
||||
|
||||
function getNumberValue(value: unknown) {
|
||||
return typeof value === 'number' ? value : value == null ? null : Number(value);
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: string) {
|
||||
if (!ts) return '-';
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return String(ts);
|
||||
return (
|
||||
d.toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}) + `.${String(d.getMilliseconds()).padStart(3, '0')}`
|
||||
);
|
||||
} catch {
|
||||
return String(ts);
|
||||
}
|
||||
}
|
||||
|
||||
function formatArpOpcode(opcode: number | null) {
|
||||
if (opcode == null) return null;
|
||||
if (Object.prototype.hasOwnProperty.call(ARP_OPCODE_LABELS, opcode)) return ARP_OPCODE_LABELS[opcode];
|
||||
if (opcode >= 26 && opcode <= 65534) return 'Unassigned';
|
||||
return `Op ${opcode}`;
|
||||
}
|
||||
|
||||
function formatIcmpType(type: number | null) {
|
||||
if (type == null) return null;
|
||||
if (Object.prototype.hasOwnProperty.call(ICMP_TYPE_LABELS, type)) return ICMP_TYPE_LABELS[type];
|
||||
if (type === 1 || type === 2 || type === 7) return 'Unassigned';
|
||||
if (type >= 20 && type <= 29) return 'Reserved (for Robustness Experiment)';
|
||||
if (type >= 44 && type <= 252) return 'Unassigned';
|
||||
return `Type ${type}`;
|
||||
}
|
||||
|
||||
function formatDnsQueryType(value: unknown) {
|
||||
const numeric = getNumberValue(value);
|
||||
if (numeric != null) return DNS_QUERY_TYPE_LABELS[numeric] ?? `TYPE${numeric}`;
|
||||
const text = getStringValue(value);
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function getFlowId(packet: PacketRow) {
|
||||
if (packet.flow_id) return String(packet.flow_id);
|
||||
const tcp = getDpiDict(packet, 'tcp');
|
||||
if (tcp?.stream != null) return `tcp:${String(tcp.stream)}`;
|
||||
const udp = getDpiDict(packet, 'udp');
|
||||
if (udp?.stream != null) return `udp:${String(udp.stream)}`;
|
||||
const tshark = getDpiDict(packet, 'tshark');
|
||||
if (tshark?.tcp_stream != null) return `tcp:${String(tshark.tcp_stream)}`;
|
||||
if (tshark?.udp_stream != null) return `udp:${String(tshark.udp_stream)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatIpProto(packet: PacketRow) {
|
||||
if (packet.ip_proto) return String(packet.ip_proto);
|
||||
if (typeof packet.ip_proto_raw === 'number') return String(packet.ip_proto_raw);
|
||||
return '-';
|
||||
}
|
||||
|
||||
function formatProtocolLabel(packet: PacketRow) {
|
||||
return formatIpProto(packet);
|
||||
}
|
||||
|
||||
function isLikelyText(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return false;
|
||||
let printable = 0;
|
||||
for (const byte of bytes) {
|
||||
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) printable += 1;
|
||||
}
|
||||
return printable / bytes.length >= 0.75;
|
||||
}
|
||||
|
||||
function decodePayloadText(bytes: Uint8Array) {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(bytes).replace(/\0/g, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTransportPayload(packet: PacketRow | null): Uint8Array | null {
|
||||
if (!packet?.raw_b64) return null;
|
||||
try {
|
||||
const bytes = base64ToBytes(packet.raw_b64);
|
||||
if (bytes.length < 14) return null;
|
||||
let etherType = (bytes[12] << 8) | bytes[13];
|
||||
let offset = 14;
|
||||
if (etherType === 0x8100 || etherType === 0x88a8) {
|
||||
if (bytes.length < 18) return null;
|
||||
etherType = (bytes[16] << 8) | bytes[17];
|
||||
offset = 18;
|
||||
}
|
||||
if (etherType === 0x0800) {
|
||||
if (bytes.length < offset + 20) return null;
|
||||
const ipHeaderLength = (bytes[offset] & 0x0f) * 4;
|
||||
const protocol = bytes[offset + 9];
|
||||
const transportOffset = offset + ipHeaderLength;
|
||||
if (protocol === 6) {
|
||||
if (bytes.length < transportOffset + 20) return null;
|
||||
const tcpHeaderLength = ((bytes[transportOffset + 12] >> 4) & 0x0f) * 4;
|
||||
return bytes.slice(Math.min(transportOffset + tcpHeaderLength, bytes.length));
|
||||
}
|
||||
if (protocol === 17) {
|
||||
if (bytes.length < transportOffset + 8) return null;
|
||||
return bytes.slice(Math.min(transportOffset + 8, bytes.length));
|
||||
}
|
||||
return bytes.slice(Math.min(transportOffset, bytes.length));
|
||||
}
|
||||
if (etherType === 0x86dd) {
|
||||
if (bytes.length < offset + 40) return null;
|
||||
const protocol = bytes[offset + 6];
|
||||
const transportOffset = offset + 40;
|
||||
if (protocol === 6) {
|
||||
if (bytes.length < transportOffset + 20) return null;
|
||||
const tcpHeaderLength = ((bytes[transportOffset + 12] >> 4) & 0x0f) * 4;
|
||||
return bytes.slice(Math.min(transportOffset + tcpHeaderLength, bytes.length));
|
||||
}
|
||||
if (protocol === 17) {
|
||||
if (bytes.length < transportOffset + 8) return null;
|
||||
return bytes.slice(Math.min(transportOffset + 8, bytes.length));
|
||||
}
|
||||
return bytes.slice(Math.min(transportOffset, bytes.length));
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDecodedPayload(packet: PacketRow | null) {
|
||||
const payload = extractTransportPayload(packet);
|
||||
if (!payload || payload.length === 0) return null;
|
||||
const text = decodePayloadText(payload);
|
||||
const textPayload = text && isLikelyText(payload) ? text : null;
|
||||
const http = packet ? getDpiDict(packet, 'http') : null;
|
||||
if (http && textPayload) {
|
||||
const separator = textPayload.includes('\r\n\r\n') ? '\r\n\r\n' : textPayload.includes('\n\n') ? '\n\n' : null;
|
||||
if (separator) {
|
||||
const [headerPart, bodyPart = ''] = textPayload.split(separator, 2);
|
||||
return { payloadText: textPayload, headersText: headerPart.trim(), bodyText: bodyPart.trim() || null };
|
||||
}
|
||||
}
|
||||
return { payloadText: textPayload, headersText: null, bodyText: null };
|
||||
}
|
||||
|
||||
function parseHttpParts(http: Record<string, unknown>) {
|
||||
const rawUri = getStringValue(http.uri);
|
||||
const host = getStringValue(http.host);
|
||||
if (!rawUri) return { path: null, queryEntries: [] as Array<[string, string]> };
|
||||
try {
|
||||
const base = rawUri.startsWith('http://') || rawUri.startsWith('https://') ? undefined : `http://${host ?? 'packet.local'}`;
|
||||
const url = new URL(rawUri, base);
|
||||
return { path: `${url.pathname}${url.hash}`, queryEntries: Array.from(url.searchParams.entries()) };
|
||||
} catch {
|
||||
const [path, query = ''] = rawUri.split('?', 2);
|
||||
return {
|
||||
path: path || rawUri,
|
||||
queryEntries: query
|
||||
.split('&')
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const [key, value = ''] = item.split('=', 2);
|
||||
return [decodeURIComponent(key), decodeURIComponent(value)] as [string, string];
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function renderKeyValueBlock(title: string, values: Array<[string, ReactNode]>) {
|
||||
const filtered = values.filter(([, value]) => value !== null && value !== undefined && value !== '');
|
||||
if (filtered.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<Text strong>{title}</Text>
|
||||
<Descriptions bordered size="small" column={2} style={{ marginTop: 8 }}>
|
||||
{filtered.map(([label, value]) => (
|
||||
<Descriptions.Item key={`${title}-${label}`} label={label}>
|
||||
{value}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTextBlock(title: string, content: string | null | undefined) {
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div>
|
||||
<Text strong>{title}</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProtocolSummary(packet: PacketRow | null): ReactNode {
|
||||
if (!packet) return <Text type="secondary">No packet selected.</Text>;
|
||||
const http = getDpiDict(packet, 'http');
|
||||
const tls = getDpiDict(packet, 'tls');
|
||||
const dns = getDpiDict(packet, 'dns');
|
||||
const tcp = getDpiDict(packet, 'tcp');
|
||||
const udp = getDpiDict(packet, 'udp');
|
||||
const icmp = getDpiDict(packet, 'icmp');
|
||||
const arp = getDpiDict(packet, 'arp');
|
||||
const tshark = getDpiDict(packet, 'tshark');
|
||||
const httpParts = http ? parseHttpParts(http) : null;
|
||||
const decodedPayload = getDecodedPayload(packet);
|
||||
const sections: ReactNode[] = [];
|
||||
|
||||
sections.push(
|
||||
renderKeyValueBlock('Packet', [
|
||||
['Timestamp', formatTimestamp(packet.timestamp)],
|
||||
['Flow ID', packet.flow_id ?? getFlowId(packet) ?? '-'],
|
||||
['Protocol', formatProtocolLabel(packet)],
|
||||
['Application', packet.app_protocol ?? packet.app_master_protocol ?? '-'],
|
||||
['Source', packet.src_ip ? `${packet.src_ip}${packet.src_port ? `:${packet.src_port}` : ''}` : '-'],
|
||||
['Destination', packet.dst_ip ? `${packet.dst_ip}${packet.dst_port ? `:${packet.dst_port}` : ''}` : '-'],
|
||||
['Path', [packet.ingress_if, packet.egress_if].filter(Boolean).join(' -> ') || '-'],
|
||||
]),
|
||||
);
|
||||
|
||||
if (http) {
|
||||
sections.push(
|
||||
renderKeyValueBlock('HTTP', [
|
||||
['Kind', getNumberValue(http.response_code) != null ? 'Response' : getStringValue(http.method) ? 'Request' : null],
|
||||
['Method', getStringValue(http.method)],
|
||||
['Host', getStringValue(http.host)],
|
||||
['URL / URI', getStringValue(http.uri)],
|
||||
['Path', httpParts?.path ?? null],
|
||||
['Status Code', getNumberValue(http.response_code) ?? null],
|
||||
['Reason', getStringValue(http.response_phrase)],
|
||||
['Content Type', getStringValue(http.content_type)],
|
||||
['User Agent', getStringValue(http.user_agent)],
|
||||
['Server', getStringValue(http.server)],
|
||||
]),
|
||||
);
|
||||
if (httpParts && httpParts.queryEntries.length > 0) {
|
||||
sections.push(renderKeyValueBlock('HTTP Query Parameters', httpParts.queryEntries.map(([key, value]) => [key, value])));
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedPayload?.headersText) sections.push(renderTextBlock('Decoded Headers', decodedPayload.headersText));
|
||||
if (decodedPayload?.bodyText) {
|
||||
const httpKind = getNumberValue(http?.response_code) != null ? 'Response Body' : getStringValue(http?.method) ? 'Request Body' : 'Decoded Body';
|
||||
sections.push(renderTextBlock(httpKind, decodedPayload.bodyText));
|
||||
} else if (decodedPayload?.payloadText) {
|
||||
sections.push(renderTextBlock('Decoded Payload', decodedPayload.payloadText));
|
||||
}
|
||||
|
||||
if (dns) {
|
||||
sections.push(
|
||||
renderKeyValueBlock('DNS', [
|
||||
['Kind', dns.is_response === true ? 'Response' : dns.is_response === false ? 'Query' : null],
|
||||
['Query Name', getStringValue(dns.query_name)],
|
||||
['Query Type', formatDnsQueryType(dns.query_type)],
|
||||
['Response Name', getStringValue(dns.response_name)],
|
||||
['A Record', Array.isArray(dns.a) ? dns.a.join(', ') : getStringValue(dns.a)],
|
||||
['AAAA Record', Array.isArray(dns.aaaa) ? dns.aaaa.join(', ') : getStringValue(dns.aaaa)],
|
||||
['CNAME', Array.isArray(dns.cname) ? dns.cname.join(', ') : getStringValue(dns.cname)],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (tls) {
|
||||
sections.push(renderKeyValueBlock('TLS', [
|
||||
['Server Name', getStringValue(tls.server_name)],
|
||||
['Version', getStringValue(tls.handshake_version)],
|
||||
['ALPN', getStringValue(tls.alpn)],
|
||||
]));
|
||||
}
|
||||
|
||||
if (tcp) {
|
||||
sections.push(renderKeyValueBlock('TCP', [
|
||||
['Packet Type', getStringValue(tcp.packet_type)],
|
||||
['Flags', Array.isArray(tcp.flag_names) ? tcp.flag_names.join(', ') : getStringValue(tcp.flag_names)],
|
||||
['Stream', getStringValue(tcp.stream)],
|
||||
['Seq', getNumberValue(tcp.seq_raw) ?? null],
|
||||
['Ack', getNumberValue(tcp.ack_raw) ?? null],
|
||||
['Payload Length', getNumberValue(tcp.payload_len) ?? null],
|
||||
['Retransmission', tcp.retransmission === true ? 'yes' : null],
|
||||
['Duplicate ACK', tcp.duplicate_ack === true ? 'yes' : null],
|
||||
['Keep Alive', tcp.keep_alive === true ? 'yes' : null],
|
||||
]));
|
||||
} else if (udp) {
|
||||
sections.push(renderKeyValueBlock('UDP', [['Stream', getStringValue(udp.stream)]]));
|
||||
}
|
||||
|
||||
if (icmp) {
|
||||
const icmpType = getNumberValue(icmp.type);
|
||||
sections.push(renderKeyValueBlock('ICMP', [
|
||||
['Type', icmpType ?? null],
|
||||
['Name', formatIcmpType(icmpType)],
|
||||
['Code', getNumberValue(icmp.code) ?? null],
|
||||
]));
|
||||
}
|
||||
|
||||
if (arp) {
|
||||
const opcode = getNumberValue(arp.opcode);
|
||||
sections.push(renderKeyValueBlock('ARP', [
|
||||
['Opcode', opcode ?? null],
|
||||
['Operation', formatArpOpcode(opcode)],
|
||||
]));
|
||||
}
|
||||
|
||||
if (tshark) {
|
||||
sections.push(renderKeyValueBlock('Dissector', [
|
||||
['Wireshark Protocol', getStringValue(tshark.protocol)],
|
||||
['Info', getStringValue(tshark.info)],
|
||||
['Protocol Stack', Array.isArray(tshark.protocol_stack) ? tshark.protocol_stack.join(' -> ') : getStringValue(tshark.protocol_stack)],
|
||||
]));
|
||||
}
|
||||
|
||||
const content = sections.filter(Boolean);
|
||||
return content.length > 0 ? <Space direction="vertical" size="middle" style={{ width: '100%' }}>{content}</Space> : <Text type="secondary">No decoded summary available for this packet.</Text>;
|
||||
}
|
||||
|
||||
export default function PacketInspectModal({
|
||||
packet,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
packet: PacketRow | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const rawHex = useMemo(() => (packet?.raw_b64 ? base64ToHex(packet.raw_b64) : '(no raw bytes available)'), [packet]);
|
||||
const reducedMetadata = useMemo(() => buildReducedMetadata(packet), [packet]);
|
||||
const fullPacketJson = useMemo(() => formatJson(packet), [packet]);
|
||||
const decodedPayload = useMemo(() => getDecodedPayload(packet), [packet]);
|
||||
const captureObservationCount = Array.isArray(packet?.capture_observations) ? packet.capture_observations.length : 0;
|
||||
|
||||
const downloadRaw = () => {
|
||||
if (!packet?.raw_b64) return;
|
||||
const blob = base64ToBlob(packet.raw_b64);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `packet_${packet.id ?? 'pkt'}.bin`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Inspect packet ${packet?.id ?? ''}`}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: '75vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={downloadRaw} type="primary" disabled={!packet?.raw_b64}>
|
||||
Download raw
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
width={1100}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'summary', label: 'Summary', children: renderProtocolSummary(packet) },
|
||||
{
|
||||
key: 'metadata',
|
||||
label: 'Metadata',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="Timestamp">{formatTimestamp(packet?.timestamp)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Flow ID">{packet?.flow_id ?? getFlowId(packet ?? {}) ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Protocol">{packet ? formatProtocolLabel(packet) : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Application">{packet?.app_protocol ?? packet?.app_master_protocol ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Host">{packet?.app_hostname ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Verdict">{packet?.verdict ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Observations">{captureObservationCount || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Source">{packet?.src_ip ? `${packet.src_ip}${packet?.src_port ? `:${packet.src_port}` : ''}` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Destination">{packet?.dst_ip ? `${packet.dst_ip}${packet?.dst_port ? `:${packet.dst_port}` : ''}` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Path">{[packet?.ingress_if, packet?.egress_if].filter(Boolean).join(' -> ') || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Capture Sources">{packet?.capture_sources?.join(', ') || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{captureObservationCount > 0 ? (
|
||||
<div>
|
||||
<Text strong>Capture observations</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>
|
||||
{formatJson(packet?.capture_observations)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<Text strong>Reduced metadata</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{formatJson(reducedMetadata)}</pre>
|
||||
</div>
|
||||
{decodedPayload?.headersText ? (
|
||||
<div>
|
||||
<Text strong>Decoded headers</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{decodedPayload.headersText}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{decodedPayload?.bodyText ? (
|
||||
<div>
|
||||
<Text strong>Decoded body</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{decodedPayload.bodyText}</pre>
|
||||
</div>
|
||||
) : decodedPayload?.payloadText ? (
|
||||
<div>
|
||||
<Text strong>Decoded payload</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{decodedPayload.payloadText}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ key: 'raw', label: 'Raw', children: <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{rawHex}</pre> },
|
||||
{ key: 'full', label: 'Full JSON', children: <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{fullPacketJson}</pre> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
1656
frontend/src/components/PacketViewer.tsx
Normal file
@@ -1,53 +1,111 @@
|
||||
// src/components/PythonEditor.tsx
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/themes/prism.css';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
// Props for the editor
|
||||
export type PythonEditorProps = {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
height?: string | number; // e.g. '200px' or 200
|
||||
height?: string | number;
|
||||
wrap?: boolean;
|
||||
fontSize?: number;
|
||||
tabSize?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_FONT =
|
||||
'"JetBrains Mono", "Fira Code", "Source Code Pro", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace';
|
||||
|
||||
export default function PythonEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
height = 480,
|
||||
className = '',
|
||||
wrap = false,
|
||||
fontSize = 14,
|
||||
tabSize = 4,
|
||||
}: PythonEditorProps) {
|
||||
const highlight = useCallback((code: string) => {
|
||||
try {
|
||||
return Prism.highlight(code, Prism.languages.python, 'python');
|
||||
} catch (e) {
|
||||
return code;
|
||||
}
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const preRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
const editorStyle = useMemo(
|
||||
const h = typeof height === 'number' ? `${height}px` : height;
|
||||
|
||||
const lineHeight = Math.round(fontSize * 1.6);
|
||||
|
||||
// Highlight whenever value changes
|
||||
useEffect(() => {
|
||||
if (preRef.current) {
|
||||
preRef.current.innerHTML = Prism.highlight(value, Prism.languages.python, 'python');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Scroll sync
|
||||
const handleScroll = () => {
|
||||
if (!textareaRef.current || !preRef.current) return;
|
||||
preRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
preRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
};
|
||||
|
||||
const sharedStyle: React.CSSProperties = useMemo(
|
||||
() => ({
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
fontSize: 14,
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
fontFamily: DEFAULT_FONT,
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
tabSize,
|
||||
whiteSpace: wrap ? 'pre-wrap' : 'pre',
|
||||
wordBreak: 'break-word',
|
||||
padding: 12,
|
||||
boxSizing: 'border-box',
|
||||
}),
|
||||
[height],
|
||||
[fontSize, lineHeight, tabSize, wrap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`python-editor ${className}`}>
|
||||
<Editor
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: h,
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Highlight layer */}
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden="true"
|
||||
className="language-python"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
margin: 0,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'auto',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Editable layer */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onValueChange={(code) => onChange(code)}
|
||||
highlight={highlight}
|
||||
padding={12}
|
||||
preClassName="language-python"
|
||||
textareaId="python-editor"
|
||||
style={editorStyle}
|
||||
readOnly={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
...sharedStyle,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
resize: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
color: 'transparent', // hide textarea text
|
||||
caretColor: 'black', // show caret
|
||||
overflow: 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
1079
frontend/src/components/ScriptManager.tsx
Normal file
357
frontend/src/components/SnifferManager.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
List,
|
||||
Modal,
|
||||
notification,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ReactElement, useMemo, useState } from 'react';
|
||||
|
||||
import { startSniffer, stopSniffer, stopSnifferByInterface } from '../api/apiClient';
|
||||
import { BridgeInfo, InterfaceInfo } from '../types/network';
|
||||
import { InterfaceSnifferStatus } from '../types/sniffer';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface SnifferManagerProps {
|
||||
interfaces: InterfaceInfo[];
|
||||
bridges: BridgeInfo[];
|
||||
statusMap: Record<string, InterfaceSnifferStatus>;
|
||||
refreshAll: () => Promise<void>;
|
||||
refreshStatus: () => Promise<void>;
|
||||
loading: boolean;
|
||||
statusLoading: boolean;
|
||||
}
|
||||
|
||||
export default function SnifferManager(props: SnifferManagerProps): ReactElement {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
|
||||
const [bridgeCaptureMode, setBridgeCaptureMode] = useState<'tc_ebpf' | 'af_packet'>('tc_ebpf');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const statusEntries = useMemo(
|
||||
() => Object.entries(props.statusMap) as [string, InterfaceSnifferStatus][],
|
||||
[props.statusMap],
|
||||
);
|
||||
|
||||
const onOpenStartModal = () => {
|
||||
form.resetFields();
|
||||
setStartMode('interface');
|
||||
setBridgeCaptureMode('tc_ebpf');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const onCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleStartSubmit = async (values: {
|
||||
target?: string;
|
||||
bridgeCaptureMode?: 'tc_ebpf' | 'af_packet';
|
||||
benchmarkMode?: boolean;
|
||||
}) => {
|
||||
const target = values.target;
|
||||
const benchmarkMode = Boolean(values.benchmarkMode);
|
||||
if (!target) {
|
||||
notification.warning({ message: 'Warning', description: 'Please select a target to start capture on.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload =
|
||||
startMode === 'interface'
|
||||
? { interface: target, benchmark_mode: benchmarkMode }
|
||||
: { bridge: target, bridge_capture_mode: values.bridgeCaptureMode ?? bridgeCaptureMode, benchmark_mode: benchmarkMode };
|
||||
const result = await startSniffer(payload);
|
||||
notification.success({
|
||||
message: 'Capture started',
|
||||
description: `Capture started on ${target} via ${result.capture_mode}${
|
||||
result.benchmark_mode ? ' in benchmark mode' : ''
|
||||
} (session ${result.session_id})`,
|
||||
});
|
||||
await props.refreshAll();
|
||||
setIsModalOpen(false);
|
||||
} catch (error: any) {
|
||||
console.error('startSniffer error', error);
|
||||
notification.error({
|
||||
message: 'Failed to start capture',
|
||||
description: error?.message ?? 'Failed to start capture',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopAll = async () => {
|
||||
try {
|
||||
await stopSniffer();
|
||||
notification.success({ message: 'All capture sessions stopped' });
|
||||
await props.refreshStatus();
|
||||
} catch (error: any) {
|
||||
console.error('stopSniffer error', error);
|
||||
notification.error({
|
||||
message: 'Failed to stop capture sessions',
|
||||
description: error?.message ?? 'Failed to stop capture sessions',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopFromList = async (ifaceName: string, sessionId?: string | null) => {
|
||||
try {
|
||||
await stopSnifferByInterface(ifaceName);
|
||||
notification.success({
|
||||
message: 'Capture stopped',
|
||||
description: `Capture stopped on interface ${ifaceName}`,
|
||||
});
|
||||
await props.refreshStatus();
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error('stopSnifferByInterface error', error);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await stopSniffer({ session_id: sessionId });
|
||||
notification.success({
|
||||
message: 'Capture stopped',
|
||||
description: `Capture stopped on interface ${ifaceName} (session ${sessionId})`,
|
||||
});
|
||||
await props.refreshStatus();
|
||||
return;
|
||||
} catch (fallbackError: any) {
|
||||
console.error('stopSniffer by session fallback failed', fallbackError);
|
||||
}
|
||||
}
|
||||
|
||||
notification.error({
|
||||
message: 'Failed to stop capture',
|
||||
description: 'Could not stop capture for this interface.',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sniffing-manager">
|
||||
<Card
|
||||
title="Capture sessions"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Tooltip title="Start a new capture session">
|
||||
<Button icon={<PlusOutlined />} onClick={onOpenStartModal} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Refresh status">
|
||||
<Button icon={<ReloadOutlined />} onClick={() => props.refreshStatus()} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Stop all capture sessions">
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStopAll} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{props.statusLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : statusEntries.length === 0 ? (
|
||||
<div style={{ padding: 12 }}>
|
||||
<Text type="secondary">No status information available.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
dataSource={statusEntries}
|
||||
renderItem={([name, status]: [string, InterfaceSnifferStatus]) => {
|
||||
const sessionId = status.session_id ?? null;
|
||||
const sessionLabel = status.session_label ?? null;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
actions={[
|
||||
status.running ? (
|
||||
<Button
|
||||
key="stop"
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => handleStopFromList(name, sessionId)}
|
||||
disabled={props.loading}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="start"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await startSniffer({ interface: name });
|
||||
notification.success({
|
||||
message: 'Capture started',
|
||||
description: `Capture started on ${name} via ${result.capture_mode} (session ${result.session_id})`,
|
||||
});
|
||||
await props.refreshStatus();
|
||||
} catch (error: any) {
|
||||
console.error('startSniffer quick', error);
|
||||
notification.error({
|
||||
message: 'Failed to start capture',
|
||||
description: error?.message ?? 'Failed to start capture',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>{name}</Text>
|
||||
{status.running ? (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
running
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag icon={<ExclamationCircleOutlined />} color="default">
|
||||
stopped
|
||||
</Tag>
|
||||
)}
|
||||
{!status.exists && <Tag color="error">missing</Tag>}
|
||||
{status.exists && !status.up && <Tag color="warning">down</Tag>}
|
||||
{status.exists && status.up && <Tag color="processing">up</Tag>}
|
||||
{sessionId && (
|
||||
<Tag>
|
||||
{sessionLabel ?? 'session'}:{' '}
|
||||
<Text code copyable={{ text: sessionId }}>
|
||||
{sessionId.slice(0, 8)}
|
||||
</Text>
|
||||
</Tag>
|
||||
)}
|
||||
{status.capture_mode && (
|
||||
<Tag color={status.capture_mode === 'af_packet' ? 'geekblue' : 'purple'}>
|
||||
{status.capture_mode === 'af_packet' ? 'AF_PACKET' : 'tc/eBPF'}
|
||||
</Tag>
|
||||
)}
|
||||
{status.benchmark_mode && <Tag color="gold">benchmark</Tag>}
|
||||
</Space>
|
||||
}
|
||||
description={<Text type="secondary">interface: {name}</Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="Start capture session"
|
||||
open={isModalOpen}
|
||||
onCancel={onCloseModal}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={props.loading}
|
||||
okText="Start"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleStartSubmit}
|
||||
initialValues={{ target: undefined, benchmarkMode: false }}
|
||||
>
|
||||
<Form.Item label="Mode" name="mode">
|
||||
<Radio.Group
|
||||
value={startMode}
|
||||
onChange={(event) => {
|
||||
setStartMode(event.target.value);
|
||||
setBridgeCaptureMode('tc_ebpf');
|
||||
form.setFieldsValue({ target: undefined, bridgeCaptureMode: 'tc_ebpf' });
|
||||
}}
|
||||
>
|
||||
<Radio value="interface">Interface</Radio>
|
||||
<Radio value="bridge">Bridge</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={startMode === 'interface' ? 'Interface' : 'Bridge'}
|
||||
name="target"
|
||||
rules={[{ required: true, message: 'Please select a target' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder={startMode === 'interface' ? 'Select interface' : 'Select bridge'}
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
{startMode === 'interface'
|
||||
? props.interfaces.map((iface) => (
|
||||
<Option key={`if:${iface.name}`} value={iface.name}>
|
||||
{iface.name}
|
||||
</Option>
|
||||
))
|
||||
: props.bridges.map((bridge) => (
|
||||
<Option key={`br:${bridge.ifname}`} value={bridge.ifname}>
|
||||
{bridge.ifname} ({bridge.members.map((member) => member.ifname).join(', ')})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{startMode === 'bridge' && (
|
||||
<Form.Item
|
||||
label="Bridge Capture Path"
|
||||
name="bridgeCaptureMode"
|
||||
initialValue="tc_ebpf"
|
||||
extra="Choose between tc/eBPF bridge telemetry or direct AF_PACKET capture on the bridge member interfaces."
|
||||
>
|
||||
<Radio.Group
|
||||
value={bridgeCaptureMode}
|
||||
onChange={(event) => {
|
||||
setBridgeCaptureMode(event.target.value);
|
||||
form.setFieldsValue({ bridgeCaptureMode: event.target.value });
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<Radio value="tc_ebpf">tc/eBPF telemetry capture</Radio>
|
||||
<Radio value="af_packet">AF_PACKET on bridge member interfaces</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Benchmark Mode"
|
||||
name="benchmarkMode"
|
||||
valuePropName="checked"
|
||||
extra={
|
||||
startMode === 'bridge' && bridgeCaptureMode === 'tc_ebpf'
|
||||
? 'Keeps tc/eBPF raw export and JSON emission, but skips backend parsing, telemetry merge, DB persistence, DPI enrichment, and live packet publishing.'
|
||||
: 'Receives packets for measurement, but skips packet parsing, packet tracking, DB persistence, DPI enrichment, and live packet publishing.'
|
||||
}
|
||||
>
|
||||
<Switch checkedChildren="Benchmark" unCheckedChildren="Normal" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1064
frontend/src/components/analysis/CommunicationViews.tsx
Normal file
104
frontend/src/components/analysis/IntelligenceRiskViews.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Card, Drawer, Space, Tag, Typography } from 'antd';
|
||||
|
||||
import type { HostIntelligenceEvidence } from '../../types/analysis';
|
||||
import {
|
||||
endpointText,
|
||||
formatBytes,
|
||||
formatTimestamp,
|
||||
protocolColor,
|
||||
renderLabelTags,
|
||||
} from './shared.tsx';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function HostDetailDrawer({
|
||||
host,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
host: HostIntelligenceEvidence | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Drawer title={host ? endpointText(host.ip_address, host.mac_address) : 'Host details'} placement="right" width={520} open={open} onClose={onClose}>
|
||||
{host == null ? null : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card size="small" title="Identity">
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Text>IP: {host.ip_address ?? '—'}</Text>
|
||||
<Text>MAC: {host.mac_address ?? '—'}</Text>
|
||||
<Text>Interfaces: {host.interfaces.join(', ') || '—'}</Text>
|
||||
<Text>First seen: {formatTimestamp(host.first_seen)} | Last seen: {formatTimestamp(host.last_seen)}</Text>
|
||||
<div>{renderLabelTags(host.hostnames, 'geekblue')}</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Protocol Profile">
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
{host.top_protocols.length === 0 ? (
|
||||
<Text type="secondary">No protocol profile available yet.</Text>
|
||||
) : (
|
||||
host.top_protocols.map((protocol) => {
|
||||
const maxCount = Math.max(...host.top_protocols.map((item) => item.packet_count), 1);
|
||||
const widthPct = (protocol.packet_count / maxCount) * 100;
|
||||
return (
|
||||
<div key={protocol.label}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Tag color={protocolColor(protocol.label)}>{protocol.label}</Tag>
|
||||
<Text>{protocol.packet_count}</Text>
|
||||
</Space>
|
||||
<div style={{ height: 8, background: '#eef3f8', borderRadius: 999, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${widthPct}%`, height: '100%', background: protocolColor(protocol.label) }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Top Peers">
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
{host.peers.length === 0 ? (
|
||||
<Text type="secondary">No peer details available yet.</Text>
|
||||
) : (
|
||||
host.peers.map((peer) => {
|
||||
const maxCount = Math.max(...host.peers.map((item) => item.packet_count), 1);
|
||||
const widthPct = (peer.packet_count / maxCount) * 100;
|
||||
return (
|
||||
<div key={`${peer.ip_address ?? 'no-ip'}|${peer.mac_address ?? 'no-mac'}`}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text>{endpointText(peer.ip_address, peer.mac_address)}</Text>
|
||||
<Text type="secondary">{peer.packet_count} packets · {formatBytes(peer.byte_count)} · {formatTimestamp(peer.last_seen)}</Text>
|
||||
<div style={{ height: 8, background: '#eef3f8', borderRadius: 999, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${widthPct}%`, height: '100%', background: '#5b8ff9' }} />
|
||||
</div>
|
||||
<div>{renderLabelTags(peer.protocols, 'purple')}</div>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Likely Services">
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
{host.services.length === 0 ? (
|
||||
<Text type="secondary">No service evidence inferred yet.</Text>
|
||||
) : (
|
||||
host.services.map((service) => (
|
||||
<div key={`${service.port ?? 'no-port'}|${service.protocol}`}>
|
||||
<Text>Port {service.port ?? '—'} · {service.protocol} · {service.packet_count} packets · {formatBytes(service.byte_count)}</Text>
|
||||
<div style={{ marginTop: 4 }}>{renderLabelTags(service.hostnames, 'geekblue')}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
769
frontend/src/components/analysis/TopologyViews.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
import { Empty } from 'antd';
|
||||
import * as d3 from 'd3';
|
||||
import {
|
||||
sankey as d3Sankey,
|
||||
sankeyLinkHorizontal,
|
||||
type SankeyGraph,
|
||||
type SankeyLink,
|
||||
type SankeyNode,
|
||||
} from 'd3-sankey';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { InterfaceProtocolPathEvidence } from '../../types/analysis';
|
||||
import {
|
||||
buildDirectionalSankeyData,
|
||||
clamp,
|
||||
endpointText,
|
||||
formatTimestamp,
|
||||
protocolColor,
|
||||
scaleVisualFor,
|
||||
sankeyVisualWeight,
|
||||
useResponsiveChartSize,
|
||||
type TopologyData,
|
||||
type TopologyLink,
|
||||
type TopologyNode,
|
||||
} from './shared.tsx';
|
||||
|
||||
type ForceNode = d3.SimulationNodeDatum & TopologyNode;
|
||||
type ForceLink = d3.SimulationLinkDatum<ForceNode> & TopologyLink;
|
||||
type SankeyNodeDatum = SankeyNode<TopologyNode, TopologyLink> & TopologyNode;
|
||||
type SankeyLinkDatum = SankeyLink<TopologyNode, TopologyLink> & TopologyLink;
|
||||
|
||||
export function SankeyTopology({ data }: { data: TopologyData }) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('sankey', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('sankey');
|
||||
const maxNodesInLayer = useMemo(
|
||||
() =>
|
||||
Math.max(
|
||||
data.nodes.filter((node) => node.kind === 'interface').length,
|
||||
data.nodes.filter((node) => node.kind === 'host').length,
|
||||
data.nodes.filter((node) => node.kind === 'ethernet').length,
|
||||
data.nodes.filter((node) => node.kind === 'ip').length,
|
||||
data.nodes.filter((node) => node.kind === 'protocol').length,
|
||||
1,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
const svgHeight = fitHeight(scaleVisual(maxNodesInLayer * 60 + 72));
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return;
|
||||
const measuredWidth = svgRef.current.parentElement?.getBoundingClientRect().width ?? chartWidth;
|
||||
if (measuredWidth <= 0) return;
|
||||
|
||||
const width = Math.floor(measuredWidth);
|
||||
const height = svgHeight;
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graph: SankeyGraph<TopologyNode, TopologyLink> = {
|
||||
nodes: data.nodes.map((node) => ({ ...node })),
|
||||
links: data.links.map((link) => ({ ...link })),
|
||||
};
|
||||
|
||||
const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>()
|
||||
.nodeId((node) => node.id)
|
||||
.nodeWidth(scaleVisual(14))
|
||||
.nodePadding(scaleVisual(maxNodesInLayer <= 4 ? 18 : maxNodesInLayer <= 8 ? 14 : 10))
|
||||
.extent([
|
||||
[scaleVisual(18), scaleVisual(20)],
|
||||
[width - scaleVisual(18), height - scaleVisual(20)],
|
||||
]);
|
||||
|
||||
const layout = sankeyLayout(graph);
|
||||
|
||||
const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.4);
|
||||
linkLayer
|
||||
.selectAll('path')
|
||||
.data(layout.links as SankeyLinkDatum[])
|
||||
.join('path')
|
||||
.attr('d', sankeyLinkHorizontal())
|
||||
.attr('stroke', (link) => {
|
||||
const target = link.target as SankeyNodeDatum;
|
||||
if ((target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol) {
|
||||
return protocolColor(target.protocol);
|
||||
}
|
||||
return '#9aa7b5';
|
||||
})
|
||||
.attr('stroke-width', (link) => Math.max(scaleVisual(1), link.width || scaleVisual(1)))
|
||||
.append('title')
|
||||
.text((link) => `${link.label}\nPackets: ${link.packetCount}`);
|
||||
|
||||
const nodeLayer = svg.append('g');
|
||||
const node = nodeLayer
|
||||
.selectAll('g')
|
||||
.data(layout.nodes as SankeyNodeDatum[])
|
||||
.join('g');
|
||||
|
||||
node
|
||||
.append('rect')
|
||||
.attr('x', (d) => d.x0 ?? 0)
|
||||
.attr('y', (d) => d.y0 ?? 0)
|
||||
.attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
|
||||
.attr('height', (d) => Math.max(scaleVisual(8), (d.y1 ?? 0) - (d.y0 ?? 0)))
|
||||
.attr('fill', (d) => {
|
||||
if (d.kind === 'interface') return '#20405d';
|
||||
if (d.kind === 'host') return '#d7e7f5';
|
||||
if (d.kind === 'ethernet') return '#d7c09c';
|
||||
if (d.kind === 'ip') return '#a8c8df';
|
||||
return d.protocol ? protocolColor(d.protocol) : '#d8d8d8';
|
||||
})
|
||||
.attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none'))
|
||||
.append('title')
|
||||
.text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
||||
|
||||
node
|
||||
.append('text')
|
||||
.attr('x', (d) => ((d.x0 ?? 0) < width / 2 ? (d.x1 ?? 0) + scaleVisual(6) : (d.x0 ?? 0) - scaleVisual(6)))
|
||||
.attr('y', (d) => ((d.y0 ?? 0) + (d.y1 ?? 0)) / 2)
|
||||
.attr('dy', '0.35em')
|
||||
.attr('text-anchor', (d) => ((d.x0 ?? 0) < width / 2 ? 'start' : 'end'))
|
||||
.attr('font-size', scaleVisual(11))
|
||||
.attr('font-weight', (d) => (d.kind === 'interface' ? 700 : 500))
|
||||
.attr('fill', '#22374f')
|
||||
.text((d) => (d.kind === 'host' ? (d.ipAddress ?? d.macAddress ?? d.label) : d.label));
|
||||
}, [chartWidth, data, maxNodesInLayer, svgHeight]);
|
||||
|
||||
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||
return <Empty description="No interface, host, and protocol relationships found yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: 'auto', display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PacketPathLanes({
|
||||
paths,
|
||||
includeEthernetLayer,
|
||||
includeIpLayer,
|
||||
}: {
|
||||
paths: InterfaceProtocolPathEvidence[];
|
||||
includeEthernetLayer: boolean;
|
||||
includeIpLayer: boolean;
|
||||
}) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('parallel', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('parallel');
|
||||
|
||||
const axisDefinitions = useMemo(() => {
|
||||
const stages = [
|
||||
{
|
||||
id: 'src',
|
||||
label: 'Source IP/MAC',
|
||||
kind: 'endpoint' as const,
|
||||
value: (path: InterfaceProtocolPathEvidence) => endpointText(path.src_ip_address, path.src_mac_address),
|
||||
},
|
||||
{
|
||||
id: 'ingress',
|
||||
label: 'Ingress',
|
||||
kind: 'interface' as const,
|
||||
value: (path: InterfaceProtocolPathEvidence) => path.ingress_interface ?? 'Unknown ingress',
|
||||
},
|
||||
...(includeEthernetLayer
|
||||
? [
|
||||
{
|
||||
id: 'ethernet',
|
||||
label: 'Ethernet',
|
||||
kind: 'ethernet' as const,
|
||||
value: (path: InterfaceProtocolPathEvidence) => path.ethernet_protocol ?? 'Unknown ethernet',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(includeIpLayer
|
||||
? [
|
||||
{
|
||||
id: 'ip',
|
||||
label: 'Internet Protocol',
|
||||
kind: 'ip' as const,
|
||||
value: (path: InterfaceProtocolPathEvidence) => path.ip_protocol ?? 'Unknown ip',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'protocol',
|
||||
label: 'App Protocol',
|
||||
kind: 'protocol' as const,
|
||||
value: (path: InterfaceProtocolPathEvidence) => path.protocol,
|
||||
},
|
||||
{
|
||||
id: 'egress',
|
||||
label: 'Egress',
|
||||
kind: 'interface' as const,
|
||||
value: (path: InterfaceProtocolPathEvidence) => path.egress_interface ?? 'Unknown egress',
|
||||
},
|
||||
{
|
||||
id: 'dst',
|
||||
label: 'Destination IP/MAC',
|
||||
kind: 'endpoint' as const,
|
||||
value: (path: InterfaceProtocolPathEvidence) => endpointText(path.dst_ip_address, path.dst_mac_address),
|
||||
},
|
||||
];
|
||||
|
||||
return stages.map((stage) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const path of paths) {
|
||||
const label = stage.value(path);
|
||||
counts.set(label, (counts.get(label) ?? 0) + path.packet_count);
|
||||
}
|
||||
const categories = Array.from(counts.entries())
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.map(([label, packetCount]) => ({ label, packetCount }));
|
||||
return { ...stage, categories };
|
||||
});
|
||||
}, [paths, includeEthernetLayer, includeIpLayer]);
|
||||
const maxCategories = useMemo(
|
||||
() => Math.max(...axisDefinitions.map((axis) => axis.categories.length), 1),
|
||||
[axisDefinitions],
|
||||
);
|
||||
const svgHeight = fitHeight(scaleVisual(maxCategories * 24 + 128));
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return;
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
if (paths.length === 0 || axisDefinitions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = chartWidth;
|
||||
const height = svgHeight;
|
||||
const sideMargin = clamp(width * 0.1, scaleVisual(48), scaleVisual(110));
|
||||
const margin = { top: scaleVisual(40), right: sideMargin, bottom: scaleVisual(24), left: sideMargin };
|
||||
svg.attr('width', width).attr('height', height);
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
const x = d3
|
||||
.scalePoint<string>()
|
||||
.domain(axisDefinitions.map((axis) => axis.id))
|
||||
.range([margin.left, width - margin.right])
|
||||
.padding(0.35);
|
||||
|
||||
const yByAxis = new Map<string, d3.ScalePoint<string>>();
|
||||
for (const axis of axisDefinitions) {
|
||||
yByAxis.set(
|
||||
axis.id,
|
||||
d3
|
||||
.scalePoint<string>()
|
||||
.domain(axis.categories.map((category) => category.label))
|
||||
.range([margin.top + scaleVisual(18), height - margin.bottom - scaleVisual(18)])
|
||||
.padding(0.45),
|
||||
);
|
||||
}
|
||||
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('rx', scaleVisual(18))
|
||||
.attr('fill', '#fbfcfe');
|
||||
const lineGenerator = d3
|
||||
.line<{ x: number; y: number }>()
|
||||
.x((point) => point.x)
|
||||
.y((point) => point.y)
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
const lineLayer = svg.append('g').attr('fill', 'none');
|
||||
const lineSelection = lineLayer
|
||||
.selectAll('path.path-line')
|
||||
.data(paths)
|
||||
.join('path')
|
||||
.attr('class', 'path-line')
|
||||
.attr('d', (path) => {
|
||||
const points = axisDefinitions
|
||||
.map((axis) => {
|
||||
const axisX = x(axis.id);
|
||||
const axisY = yByAxis.get(axis.id)?.(axis.value(path));
|
||||
if (axisX == null || axisY == null) return null;
|
||||
return { x: axisX, y: axisY };
|
||||
})
|
||||
.filter((point): point is { x: number; y: number } => point !== null);
|
||||
return lineGenerator(points) ?? '';
|
||||
})
|
||||
.attr('stroke', (path) => protocolColor(path.protocol))
|
||||
.attr('stroke-opacity', (path) => clamp(0.14 + Math.log10(Math.max(path.packet_count, 1)) * 0.08, 0.14, 0.5))
|
||||
.attr('stroke-width', (path) =>
|
||||
clamp(Math.sqrt(Math.max(path.packet_count, 1)) * scaleVisual(1.1), scaleVisual(2.2), scaleVisual(8)),
|
||||
);
|
||||
|
||||
lineSelection
|
||||
.append('title')
|
||||
.text((path) =>
|
||||
[
|
||||
`${endpointText(path.src_ip_address, path.src_mac_address)} -> ${path.ingress_interface ?? 'Unknown ingress'}`,
|
||||
`${path.protocol} -> ${path.egress_interface ?? 'Unknown egress'}`,
|
||||
`${endpointText(path.dst_ip_address, path.dst_mac_address)}`,
|
||||
`Packets: ${path.packet_count}`,
|
||||
`Last seen: ${formatTimestamp(path.last_seen)}`,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const axisLayer = svg.append('g');
|
||||
const activeFilters = new Map<string, Set<string>>();
|
||||
const pathMatchesFilters = (path: InterfaceProtocolPathEvidence) =>
|
||||
axisDefinitions.every((axis) => {
|
||||
const allowed = activeFilters.get(axis.id);
|
||||
if (allowed == null || allowed.size === 0) return true;
|
||||
return allowed.has(axis.value(path));
|
||||
});
|
||||
|
||||
const updateLineStyles = () => {
|
||||
lineSelection
|
||||
.attr('stroke-opacity', (path) => {
|
||||
const matches = pathMatchesFilters(path);
|
||||
if (matches) return clamp(0.2 + Math.log10(Math.max(path.packet_count, 1)) * 0.1, 0.2, 0.7);
|
||||
return 0.035;
|
||||
})
|
||||
.attr('stroke-width', (path) => {
|
||||
if (!pathMatchesFilters(path)) return scaleVisual(1.2);
|
||||
return clamp(
|
||||
Math.sqrt(Math.max(path.packet_count, 1)) * scaleVisual(1.25),
|
||||
scaleVisual(2.4),
|
||||
scaleVisual(9),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
for (const [axisIndex, axis] of axisDefinitions.entries()) {
|
||||
const axisX = x(axis.id);
|
||||
const yScale = yByAxis.get(axis.id);
|
||||
if (axisX == null || yScale == null) continue;
|
||||
|
||||
axisLayer
|
||||
.append('line')
|
||||
.attr('x1', axisX)
|
||||
.attr('x2', axisX)
|
||||
.attr('y1', margin.top)
|
||||
.attr('y2', height - margin.bottom)
|
||||
.attr('stroke', '#b8c6d5')
|
||||
.attr('stroke-width', scaleVisual(2));
|
||||
axisLayer
|
||||
.append('text')
|
||||
.attr('x', axisX)
|
||||
.attr('y', margin.top - scaleVisual(14))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', scaleVisual(12))
|
||||
.attr('font-weight', 700)
|
||||
.attr('fill', '#42586f')
|
||||
.text(axis.label);
|
||||
|
||||
const labelAnchor = axisIndex < axisDefinitions.length / 2 ? 'end' : 'start';
|
||||
const labelOffset = labelAnchor === 'end' ? -scaleVisual(10) : scaleVisual(10);
|
||||
|
||||
axisLayer
|
||||
.selectAll(`circle.axis-${axis.id}`)
|
||||
.data(axis.categories)
|
||||
.join('circle')
|
||||
.attr('cx', axisX)
|
||||
.attr('cy', (category) => yScale(category.label) ?? height / 2)
|
||||
.attr('r', (category) =>
|
||||
clamp(Math.sqrt(Math.max(category.packetCount, 1)) * scaleVisual(0.28), scaleVisual(3), scaleVisual(7)),
|
||||
)
|
||||
.attr('fill', axis.kind === 'protocol' ? '#35566f' : axis.kind === 'interface' ? '#20405d' : '#8eaac4')
|
||||
.attr('opacity', 0.95);
|
||||
|
||||
axisLayer
|
||||
.selectAll(`text.axis-label-${axis.id}`)
|
||||
.data(axis.categories)
|
||||
.join('text')
|
||||
.attr('x', axisX + labelOffset)
|
||||
.attr('y', (category) => (yScale(category.label) ?? height / 2) + scaleVisual(4))
|
||||
.attr('text-anchor', labelAnchor)
|
||||
.attr('font-size', scaleVisual(11))
|
||||
.attr('fill', '#41566d')
|
||||
.text((category) => category.label);
|
||||
|
||||
const brush = d3
|
||||
.brushY()
|
||||
.extent([
|
||||
[axisX - scaleVisual(22), margin.top],
|
||||
[axisX + scaleVisual(22), height - margin.bottom],
|
||||
])
|
||||
.on('brush end', (event) => {
|
||||
const selection = event.selection as [number, number] | null;
|
||||
if (selection == null) {
|
||||
activeFilters.delete(axis.id);
|
||||
updateLineStyles();
|
||||
return;
|
||||
}
|
||||
const [y0, y1] = selection[0] <= selection[1] ? selection : [selection[1], selection[0]];
|
||||
const labels = axis.categories
|
||||
.filter((category) => {
|
||||
const yValue = yScale(category.label);
|
||||
return yValue != null && yValue >= y0 && yValue <= y1;
|
||||
})
|
||||
.map((category) => category.label);
|
||||
activeFilters.set(axis.id, new Set(labels));
|
||||
updateLineStyles();
|
||||
});
|
||||
|
||||
const brushGroup = axisLayer.append('g').attr('class', `brush brush-${axis.id}`).call(brush);
|
||||
brushGroup.selectAll('.selection').attr('fill', '#8fb7d8').attr('fill-opacity', 0.18).attr('stroke', '#4f7ba3');
|
||||
brushGroup.selectAll('.handle').attr('fill', '#4f7ba3').attr('fill-opacity', 0.9);
|
||||
}
|
||||
|
||||
updateLineStyles();
|
||||
}, [axisDefinitions, chartWidth, paths, svgHeight]);
|
||||
|
||||
if (paths.length === 0) {
|
||||
return <Empty description="No packet path view available yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: `${svgHeight}px`, display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForceTopology({ data }: { data: TopologyData }) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('force', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('force');
|
||||
const svgHeight = fitHeight(scaleVisual(Math.max(300, data.nodes.length * 18 + 180)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return;
|
||||
const width = chartWidth;
|
||||
const height = svgHeight;
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
svg.attr('width', width).attr('height', height);
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node }));
|
||||
const links: ForceLink[] = data.links.map((link) => ({ ...link }));
|
||||
const groupedNodes = {
|
||||
interface: nodes
|
||||
.filter((node) => node.kind === 'interface')
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
host: nodes
|
||||
.filter((node) => node.kind === 'host')
|
||||
.sort((left, right) =>
|
||||
(left.ipAddress ?? left.macAddress ?? left.label).localeCompare(
|
||||
right.ipAddress ?? right.macAddress ?? right.label,
|
||||
),
|
||||
),
|
||||
ethernet: nodes
|
||||
.filter((node) => node.kind === 'ethernet')
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
ip: nodes.filter((node) => node.kind === 'ip').sort((left, right) => left.label.localeCompare(right.label)),
|
||||
protocol: nodes
|
||||
.filter((node) => node.kind === 'protocol')
|
||||
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||
};
|
||||
|
||||
const distributedY = (group: ForceNode[], top: number, bottom: number) => {
|
||||
const targets = new Map<string, number>();
|
||||
if (group.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
if (group.length === 1) {
|
||||
targets.set(group[0].id, (top + bottom) / 2);
|
||||
return targets;
|
||||
}
|
||||
const step = (bottom - top) / (group.length - 1);
|
||||
group.forEach((node, index) => targets.set(node.id, top + step * index));
|
||||
return targets;
|
||||
};
|
||||
|
||||
const interfaceY = distributedY(groupedNodes.interface, scaleVisual(120), height - scaleVisual(120));
|
||||
const hostY = distributedY(groupedNodes.host, scaleVisual(90), height - scaleVisual(90));
|
||||
const ethernetY = distributedY(groupedNodes.ethernet, scaleVisual(120), height - scaleVisual(120));
|
||||
const ipY = distributedY(groupedNodes.ip, scaleVisual(120), height - scaleVisual(120));
|
||||
const protocolY = distributedY(groupedNodes.protocol, scaleVisual(120), height - scaleVisual(120));
|
||||
const targetY = (node: ForceNode) =>
|
||||
interfaceY.get(node.id) ??
|
||||
hostY.get(node.id) ??
|
||||
ethernetY.get(node.id) ??
|
||||
ipY.get(node.id) ??
|
||||
protocolY.get(node.id) ??
|
||||
height / 2;
|
||||
|
||||
const simulation = d3
|
||||
.forceSimulation<ForceNode>(nodes)
|
||||
.force(
|
||||
'link',
|
||||
d3
|
||||
.forceLink<ForceNode, ForceLink>(links)
|
||||
.id((node) => node.id)
|
||||
.distance((link) => ((link.source as ForceNode).kind === 'interface' ? scaleVisual(250) : scaleVisual(200)))
|
||||
.strength((link) => ((link.source as ForceNode).kind === 'interface' ? 0.45 : 0.35)),
|
||||
)
|
||||
.force('charge', d3.forceManyBody().strength(-scaleVisual(720)))
|
||||
.force(
|
||||
'collision',
|
||||
d3.forceCollide<ForceNode>().radius((node) => {
|
||||
if (node.kind === 'interface') return scaleVisual(52);
|
||||
if (node.kind === 'host') return scaleVisual(44);
|
||||
if (node.kind === 'ethernet') return scaleVisual(36);
|
||||
if (node.kind === 'ip') return scaleVisual(35);
|
||||
return scaleVisual(34);
|
||||
}),
|
||||
)
|
||||
.force(
|
||||
'x',
|
||||
d3
|
||||
.forceX<ForceNode>()
|
||||
.x((node) => {
|
||||
if (node.kind === 'interface') return width * 0.14;
|
||||
if (node.kind === 'host') return width * 0.34;
|
||||
if (node.kind === 'ethernet') return width * 0.54;
|
||||
if (node.kind === 'ip') return width * 0.72;
|
||||
return width * 0.88;
|
||||
})
|
||||
.strength(0.42),
|
||||
)
|
||||
.force(
|
||||
'y',
|
||||
d3
|
||||
.forceY<ForceNode>()
|
||||
.y((node) => targetY(node))
|
||||
.strength(0.22),
|
||||
)
|
||||
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.06));
|
||||
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('rx', scaleVisual(18))
|
||||
.attr('fill', '#fbfcfe');
|
||||
|
||||
const link = svg
|
||||
.append('g')
|
||||
.attr('stroke-opacity', 0.45)
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.attr('stroke', (d) => {
|
||||
const target = d.target as ForceNode;
|
||||
return (target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol
|
||||
? protocolColor(target.protocol)
|
||||
: '#92a1b2';
|
||||
})
|
||||
.attr('stroke-width', (d) => scaleVisual(sankeyVisualWeight(d.packetCount)));
|
||||
|
||||
link.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
||||
|
||||
const node = svg.append('g').selectAll('g').data(nodes).join('g');
|
||||
|
||||
node
|
||||
.append('circle')
|
||||
.attr('r', (d) => {
|
||||
if (d.kind === 'interface') return scaleVisual(26);
|
||||
if (d.kind === 'host') return scaleVisual(22);
|
||||
if (d.kind === 'ethernet') return scaleVisual(19);
|
||||
if (d.kind === 'ip') return scaleVisual(18);
|
||||
return scaleVisual(18);
|
||||
})
|
||||
.attr('fill', (d) => {
|
||||
if (d.kind === 'interface') return '#20405d';
|
||||
if (d.kind === 'host') return '#d7e7f5';
|
||||
if (d.kind === 'ethernet') return '#d7c09c';
|
||||
if (d.kind === 'ip') return '#a8c8df';
|
||||
return d.protocol ? protocolColor(d.protocol) : '#cfd7df';
|
||||
})
|
||||
.attr('stroke', (d) => (d.kind === 'host' ? '#8aa8c6' : '#ffffff'))
|
||||
.attr('stroke-width', scaleVisual(2));
|
||||
|
||||
node
|
||||
.append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', scaleVisual(40))
|
||||
.attr('font-size', scaleVisual(11))
|
||||
.attr('font-weight', 600)
|
||||
.attr('fill', '#29445d')
|
||||
.text((d) => (d.kind === 'host' ? (d.ipAddress ?? d.macAddress ?? 'host') : d.label));
|
||||
|
||||
node.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', (d) => (d.source as ForceNode).x ?? 0)
|
||||
.attr('y1', (d) => (d.source as ForceNode).y ?? 0)
|
||||
.attr('x2', (d) => (d.target as ForceNode).x ?? 0)
|
||||
.attr('y2', (d) => (d.target as ForceNode).y ?? 0);
|
||||
|
||||
node.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
||||
});
|
||||
|
||||
return () => simulation.stop();
|
||||
}, [chartWidth, data, svgHeight]);
|
||||
|
||||
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||
return <Empty description="No graph data available yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: `${svgHeight}px`, display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtocolHeatmap({ data }: { data: TopologyData }) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('heatmap', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('heatmap');
|
||||
const maxProtocolLabelLength = useMemo(
|
||||
() => d3.max(data.protocols, (protocol) => protocol.length) ?? 0,
|
||||
[data.protocols],
|
||||
);
|
||||
const topMargin = clamp(
|
||||
scaleVisual(maxProtocolLabelLength * 3.1 + 24),
|
||||
scaleVisual(56),
|
||||
scaleVisual(96),
|
||||
);
|
||||
const svgHeight = fitHeight(Math.max(scaleVisual(320), topMargin + scaleVisual(54 + data.heatmapRows.length * 34)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return;
|
||||
|
||||
const maxHostLabelLength = d3.max(data.heatmapRows, (row) => row.hostLabel.length) ?? 0;
|
||||
const margin = {
|
||||
top: topMargin,
|
||||
right: scaleVisual(22),
|
||||
bottom: scaleVisual(24),
|
||||
left: clamp(scaleVisual(maxHostLabelLength * 6.4 + 18), scaleVisual(150), scaleVisual(280)),
|
||||
};
|
||||
const usableWidth = chartWidth;
|
||||
const cellHeight = scaleVisual(34);
|
||||
const width = usableWidth;
|
||||
const height = svgHeight;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
svg.attr('width', width).attr('height', height);
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
if (data.protocols.length === 0 || data.heatmapRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const x = d3
|
||||
.scaleBand<string>()
|
||||
.domain(data.protocols)
|
||||
.range([margin.left, width - margin.right])
|
||||
.paddingInner(0.08);
|
||||
const y = d3
|
||||
.scaleBand<string>()
|
||||
.domain(data.heatmapRows.map((row) => row.hostId))
|
||||
.range([margin.top, height - margin.bottom])
|
||||
.paddingInner(0.08);
|
||||
const maxValue =
|
||||
d3.max(data.heatmapRows.flatMap((row) => data.protocols.map((protocol) => row.values[protocol] || 0))) ?? 1;
|
||||
const color = d3.scaleSequential(d3.interpolateYlGnBu).domain([0, maxValue]);
|
||||
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('rx', scaleVisual(18))
|
||||
.attr('fill', '#fbfcfe');
|
||||
|
||||
const cells = svg.append('g');
|
||||
for (const row of data.heatmapRows) {
|
||||
for (const protocol of data.protocols) {
|
||||
const value = row.values[protocol] || 0;
|
||||
const cell = cells.append('g').attr('transform', `translate(${x(protocol) ?? 0},${y(row.hostId) ?? 0})`);
|
||||
|
||||
cell
|
||||
.append('rect')
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('height', y.bandwidth())
|
||||
.attr('rx', scaleVisual(8))
|
||||
.attr('fill', value > 0 ? color(value) : '#eef3f8')
|
||||
.attr('stroke', '#dce5ef');
|
||||
|
||||
if (value > 0) {
|
||||
cell
|
||||
.append('text')
|
||||
.attr('x', x.bandwidth() / 2)
|
||||
.attr('y', y.bandwidth() / 2 + scaleVisual(4))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', scaleVisual(11))
|
||||
.attr('font-weight', 700)
|
||||
.attr('fill', value > maxValue * 0.45 ? '#ffffff' : '#23415c')
|
||||
.text(value);
|
||||
}
|
||||
|
||||
cell.append('title').text(`${row.hostLabel}\n${protocol}: ${value} packets`);
|
||||
}
|
||||
}
|
||||
|
||||
svg
|
||||
.append('g')
|
||||
.selectAll('text.protocol-label')
|
||||
.data(data.protocols)
|
||||
.join('text')
|
||||
.attr('class', 'protocol-label')
|
||||
.attr('x', (protocol) => (x(protocol) ?? 0) + x.bandwidth() / 2)
|
||||
.attr('y', margin.top - scaleVisual(12))
|
||||
.attr('transform', (protocol) => `rotate(-35, ${(x(protocol) ?? 0) + x.bandwidth() / 2}, ${margin.top - scaleVisual(12)})`)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', scaleVisual(12))
|
||||
.attr('font-weight', 600)
|
||||
.attr('fill', '#29445d')
|
||||
.text((protocol) => protocol);
|
||||
|
||||
svg
|
||||
.append('g')
|
||||
.selectAll('text.host-label')
|
||||
.data(data.heatmapRows)
|
||||
.join('text')
|
||||
.attr('class', 'host-label')
|
||||
.attr('x', margin.left - scaleVisual(12))
|
||||
.attr('y', (row) => (y(row.hostId) ?? 0) + y.bandwidth() / 2 + scaleVisual(4))
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('font-size', scaleVisual(12))
|
||||
.attr('fill', '#29445d')
|
||||
.text((row) => row.hostLabel);
|
||||
}, [chartWidth, data, svgHeight, topMargin]);
|
||||
|
||||
if (data.protocols.length === 0 || data.heatmapRows.length === 0) {
|
||||
return <Empty description="No protocol heatmap data available yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: 'auto', display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectionalSankeyTopology({
|
||||
paths,
|
||||
includeEthernetLayer,
|
||||
includeIpLayer,
|
||||
}: {
|
||||
paths: InterfaceProtocolPathEvidence[];
|
||||
includeEthernetLayer: boolean;
|
||||
includeIpLayer: boolean;
|
||||
}) {
|
||||
const topologyData = useMemo(
|
||||
() => buildDirectionalSankeyData(paths, { includeEthernetLayer, includeIpLayer }),
|
||||
[paths, includeEthernetLayer, includeIpLayer],
|
||||
);
|
||||
|
||||
return <SankeyTopology data={topologyData} />;
|
||||
}
|
||||
926
frontend/src/components/analysis/shared.tsx
Normal file
@@ -0,0 +1,926 @@
|
||||
import { Space, Tag } from 'antd';
|
||||
import * as d3 from 'd3';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import type {
|
||||
ConversationEvidence,
|
||||
InterfaceHostProtocolEvidence,
|
||||
InterfaceProtocolAttachment,
|
||||
InterfaceProtocolPathEvidence,
|
||||
LabelCountEvidence,
|
||||
} from '../../types/analysis';
|
||||
import type { PacketRow } from '../../types/packets';
|
||||
|
||||
export type GraphNodeKind = 'interface' | 'host' | 'protocol';
|
||||
export type TopologyLayerKind = GraphNodeKind | 'ethernet' | 'ip';
|
||||
|
||||
export type TopologyNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: TopologyLayerKind;
|
||||
packetCount: number;
|
||||
interfaceName?: string;
|
||||
ipAddress?: string | null;
|
||||
macAddress?: string | null;
|
||||
protocol?: string;
|
||||
};
|
||||
|
||||
export type TopologyLink = {
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
packetCount: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type HeatmapRow = {
|
||||
hostId: string;
|
||||
hostLabel: string;
|
||||
interfaceName: string;
|
||||
values: Record<string, number>;
|
||||
};
|
||||
|
||||
export type ProtocolTableRow = {
|
||||
key: string;
|
||||
interface: string;
|
||||
ip_address?: string | null;
|
||||
mac_address?: string | null;
|
||||
host_packet_count: number;
|
||||
protocol: string;
|
||||
protocol_packet_count: number;
|
||||
accept_count: number;
|
||||
drop_count: number;
|
||||
reject_count: number;
|
||||
unknown_count: number;
|
||||
last_seen: string;
|
||||
};
|
||||
|
||||
export type TopologyData = {
|
||||
nodes: TopologyNode[];
|
||||
links: TopologyLink[];
|
||||
heatmapRows: HeatmapRow[];
|
||||
protocols: string[];
|
||||
tableRows: ProtocolTableRow[];
|
||||
};
|
||||
|
||||
export type TopologyOptions = {
|
||||
includeEthernetLayer: boolean;
|
||||
includeIpLayer: boolean;
|
||||
};
|
||||
|
||||
export function formatTimestamp(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
const date = new Date(value);
|
||||
return (
|
||||
date.toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}) + `.${String(date.getMilliseconds()).padStart(3, '0')}`
|
||||
);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBytes(value?: number | null) {
|
||||
const amount = Number(value ?? 0);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return '0 B';
|
||||
if (amount < 1024) return `${amount} B`;
|
||||
if (amount < 1024 ** 2) return `${(amount / 1024).toFixed(1)} KB`;
|
||||
if (amount < 1024 ** 3) return `${(amount / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(amount / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(value?: number | null) {
|
||||
const duration = Number(value ?? 0);
|
||||
if (!Number.isFinite(duration) || duration <= 0) return '0 ms';
|
||||
if (duration < 1000) return `${duration} ms`;
|
||||
const seconds = duration / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(2)} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
export function endpointText(ipAddress?: string | null, macAddress?: string | null) {
|
||||
return ipAddress ?? macAddress ?? 'unknown endpoint';
|
||||
}
|
||||
|
||||
export function normalizeIpAddress(value?: string | null) {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return null;
|
||||
const slashIndex = trimmed.indexOf('/');
|
||||
return slashIndex >= 0 ? trimmed.slice(0, slashIndex) : trimmed;
|
||||
}
|
||||
|
||||
export function conversationRowKey(row: ConversationEvidence) {
|
||||
return [
|
||||
row.src_ip_address,
|
||||
row.src_mac_address,
|
||||
row.src_port,
|
||||
row.dst_ip_address,
|
||||
row.dst_mac_address,
|
||||
row.dst_port,
|
||||
row.protocol,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function packetEventLabel(packet: PacketRow) {
|
||||
const tcpLabel = packetTcpSummary(packet);
|
||||
const activityLabel = packetActivityText(packet);
|
||||
if (tcpLabel && activityLabel && tcpLabel !== activityLabel) {
|
||||
return `${tcpLabel} · ${activityLabel}`;
|
||||
}
|
||||
if (activityLabel) {
|
||||
return activityLabel;
|
||||
}
|
||||
if (tcpLabel) {
|
||||
return tcpLabel;
|
||||
}
|
||||
return String(packet.app_protocol ?? packet.ip_proto ?? packet.eth_type ?? 'Packet');
|
||||
}
|
||||
|
||||
export function packetTcpSummary(packet: PacketRow) {
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const tcpMeta = asRecord(dpiMetadata?.tcp);
|
||||
const tcpFlags = Array.isArray(tcpMeta?.flag_names)
|
||||
? tcpMeta.flag_names.filter((flag): flag is string => typeof flag === 'string')
|
||||
: [];
|
||||
const tcpPacketType = typeof tcpMeta?.packet_type === 'string' ? tcpMeta.packet_type : null;
|
||||
return tcpFlags.length > 0 ? tcpFlags.join('-') : tcpPacketType;
|
||||
}
|
||||
|
||||
export function packetActivityText(packet: PacketRow) {
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const httpMeta = asRecord(dpiMetadata?.http);
|
||||
const dnsMeta = asRecord(dpiMetadata?.dns);
|
||||
const tlsMeta = asRecord(dpiMetadata?.tls);
|
||||
const arpMeta = asRecord(dpiMetadata?.arp);
|
||||
const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null;
|
||||
const uri =
|
||||
typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null;
|
||||
if (method) {
|
||||
return `${method} ${uri ?? ''}`.trim();
|
||||
}
|
||||
const responseCode = httpMeta?.response_code;
|
||||
const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : '';
|
||||
if (typeof responseCode === 'number' || typeof responseCode === 'string') {
|
||||
return `${responseCode} ${responsePhrase}`.trim();
|
||||
}
|
||||
|
||||
const dnsName =
|
||||
typeof dnsMeta?.query_name === 'string'
|
||||
? dnsMeta.query_name
|
||||
: typeof dnsMeta?.response_name === 'string'
|
||||
? dnsMeta.response_name
|
||||
: null;
|
||||
if (dnsName) {
|
||||
return dnsName;
|
||||
}
|
||||
|
||||
const serverName =
|
||||
typeof tlsMeta?.server_name === 'string'
|
||||
? tlsMeta.server_name
|
||||
: typeof tlsMeta?.sni === 'string'
|
||||
? tlsMeta.sni
|
||||
: null;
|
||||
if (serverName) {
|
||||
return serverName;
|
||||
}
|
||||
|
||||
if (typeof arpMeta?.target_proto_ipv4 === 'string') {
|
||||
return arpMeta.target_proto_ipv4;
|
||||
}
|
||||
|
||||
const appProtocol = typeof packet.app_protocol === 'string' ? packet.app_protocol : null;
|
||||
const ipProtocol = typeof packet.ip_proto === 'string' ? packet.ip_proto : null;
|
||||
const ethernetProtocol = typeof packet.eth_type === 'string' ? packet.eth_type : null;
|
||||
if (appProtocol && appProtocol !== ipProtocol && appProtocol !== ethernetProtocol) {
|
||||
return appProtocol;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function packetHttpDetailText(packet: PacketRow) {
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const httpMeta = asRecord(dpiMetadata?.http);
|
||||
const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null;
|
||||
const uri =
|
||||
typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null;
|
||||
if (method) {
|
||||
return `${method} ${uri ?? ''}`.trim();
|
||||
}
|
||||
const responseCode = httpMeta?.response_code;
|
||||
const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : '';
|
||||
if (typeof responseCode === 'number' || typeof responseCode === 'string') {
|
||||
return `${responseCode} ${responsePhrase}`.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function packetDnsDetailText(packet: PacketRow) {
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const dnsMeta = asRecord(dpiMetadata?.dns);
|
||||
if (dnsMeta == null) return '';
|
||||
const queryType = typeof dnsMeta.query_type === 'string' ? dnsMeta.query_type : null;
|
||||
const queryName = typeof dnsMeta.query_name === 'string' ? dnsMeta.query_name : null;
|
||||
const responseName = typeof dnsMeta.response_name === 'string' ? dnsMeta.response_name : null;
|
||||
if (dnsMeta.is_response === false) {
|
||||
return `Query${queryType ? ` ${queryType}` : ''}${queryName ? ` ${queryName}` : ''}`.trim();
|
||||
}
|
||||
if (dnsMeta.is_response === true) {
|
||||
return `Response${responseName ? ` ${responseName}` : queryName ? ` ${queryName}` : ''}`.trim();
|
||||
}
|
||||
return queryName ?? responseName ?? '';
|
||||
}
|
||||
|
||||
export function packetTlsDetailText(packet: PacketRow) {
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const tlsMeta = asRecord(dpiMetadata?.tls);
|
||||
if (tlsMeta == null) return '';
|
||||
const version = typeof tlsMeta.handshake_version === 'string' ? tlsMeta.handshake_version : null;
|
||||
const serverName =
|
||||
typeof tlsMeta.server_name === 'string'
|
||||
? tlsMeta.server_name
|
||||
: typeof tlsMeta.sni === 'string'
|
||||
? tlsMeta.sni
|
||||
: null;
|
||||
const alpn = typeof tlsMeta.alpn === 'string' ? tlsMeta.alpn : null;
|
||||
return [version, serverName, alpn].filter((value): value is string => Boolean(value)).join(' · ');
|
||||
}
|
||||
|
||||
export function packetArpDetailText(packet: PacketRow) {
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const arpMeta = asRecord(dpiMetadata?.arp);
|
||||
if (arpMeta == null) return '';
|
||||
const opcode = typeof arpMeta.opcode === 'number' ? arpMeta.opcode : null;
|
||||
const opcodeLabel =
|
||||
opcode === 1
|
||||
? 'Request'
|
||||
: opcode === 2
|
||||
? 'Reply'
|
||||
: opcode === 10
|
||||
? 'NAK'
|
||||
: opcode === 16
|
||||
? 'InARP'
|
||||
: opcode === 24
|
||||
? 'NAK Reply'
|
||||
: opcode === 25
|
||||
? 'Peer Request'
|
||||
: opcode != null
|
||||
? `Op ${opcode}`
|
||||
: null;
|
||||
const targetIp = typeof arpMeta.target_proto_ipv4 === 'string' ? arpMeta.target_proto_ipv4 : null;
|
||||
return [opcodeLabel, targetIp].filter((value): value is string => Boolean(value)).join(' · ');
|
||||
}
|
||||
|
||||
export function packetIcmpDetailText(packet: PacketRow) {
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const icmpMeta = asRecord(dpiMetadata?.icmp);
|
||||
if (icmpMeta == null) return '';
|
||||
const icmpType =
|
||||
typeof icmpMeta.type_label === 'string'
|
||||
? icmpMeta.type_label
|
||||
: typeof icmpMeta.type === 'string'
|
||||
? icmpMeta.type
|
||||
: typeof icmpMeta.icmp_type === 'string'
|
||||
? icmpMeta.icmp_type
|
||||
: null;
|
||||
const code =
|
||||
typeof icmpMeta.code === 'number'
|
||||
? String(icmpMeta.code)
|
||||
: typeof icmpMeta.icmp_code === 'string'
|
||||
? icmpMeta.icmp_code
|
||||
: null;
|
||||
return [icmpType, code ? `code ${code}` : null].filter((value): value is string => Boolean(value)).join(' · ');
|
||||
}
|
||||
|
||||
function stringList(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '');
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return [value];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function tcpFlagColor(flagName: string) {
|
||||
const normalized = flagName.toUpperCase();
|
||||
if (normalized === 'SYN') return 'blue';
|
||||
if (normalized === 'ACK') return 'cyan';
|
||||
if (normalized === 'PSH') return 'green';
|
||||
if (normalized === 'FIN') return 'orange';
|
||||
if (normalized === 'RST') return 'red';
|
||||
if (normalized === 'URG') return 'volcano';
|
||||
if (normalized === 'ECE' || normalized === 'CWR') return 'purple';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export function renderPacketBadges(packet: PacketRow): ReactNode[] {
|
||||
const badges: ReactNode[] = [];
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
return badges.concat(renderPacketTcpBadges(packet));
|
||||
}
|
||||
|
||||
export function renderPacketTcpBadges(packet: PacketRow): ReactNode[] {
|
||||
const badges: ReactNode[] = [];
|
||||
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||
const tcpMeta = asRecord(dpiMetadata?.tcp);
|
||||
const tsharkMeta = asRecord(dpiMetadata?.tshark);
|
||||
|
||||
const flagNames = Array.from(
|
||||
new Set([...stringList(tcpMeta?.flag_names), ...stringList(tsharkMeta?.tcp_flag_names)]),
|
||||
);
|
||||
for (const flagName of flagNames) {
|
||||
badges.push(
|
||||
<Tag key={`protocol-flag-${flagName}`} color={tcpFlagColor(flagName)} style={{ marginInlineEnd: 0 }}>
|
||||
{flagName}
|
||||
</Tag>,
|
||||
);
|
||||
}
|
||||
|
||||
const tcpPacketType =
|
||||
typeof tcpMeta?.packet_type === 'string'
|
||||
? tcpMeta.packet_type
|
||||
: typeof tsharkMeta?.tcp_packet_type === 'string'
|
||||
? tsharkMeta.tcp_packet_type
|
||||
: null;
|
||||
if (tcpPacketType && flagNames.length === 0) {
|
||||
badges.push(
|
||||
<Tag key={`protocol-type-${tcpPacketType}`} color="default" style={{ marginInlineEnd: 0 }}>
|
||||
{tcpPacketType}
|
||||
</Tag>,
|
||||
);
|
||||
}
|
||||
|
||||
if (tcpMeta?.retransmission === true) {
|
||||
badges.push(
|
||||
<Tag key="protocol-retransmission" color="red" style={{ marginInlineEnd: 0 }}>
|
||||
Retransmission
|
||||
</Tag>,
|
||||
);
|
||||
}
|
||||
if (tcpMeta?.duplicate_ack === true) {
|
||||
badges.push(
|
||||
<Tag key="protocol-dup-ack" color="volcano" style={{ marginInlineEnd: 0 }}>
|
||||
Dup ACK
|
||||
</Tag>,
|
||||
);
|
||||
}
|
||||
if (tcpMeta?.keep_alive === true) {
|
||||
badges.push(
|
||||
<Tag key="protocol-keepalive" color="lime" style={{ marginInlineEnd: 0 }}>
|
||||
Keep-Alive
|
||||
</Tag>,
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
export function endpointMatches(
|
||||
packetIp: string | null | undefined,
|
||||
packetMac: string | null | undefined,
|
||||
targetIp: string | null | undefined,
|
||||
targetMac: string | null | undefined,
|
||||
) {
|
||||
if (targetIp == null && targetMac == null) {
|
||||
return false;
|
||||
}
|
||||
const ipMatches = targetIp == null || normalizeIpAddress(packetIp) === normalizeIpAddress(targetIp);
|
||||
const macMatches = targetMac == null || packetMac === targetMac;
|
||||
return ipMatches && macMatches;
|
||||
}
|
||||
|
||||
export function packetDirection(packet: PacketRow, conversation: ConversationEvidence) {
|
||||
const forward =
|
||||
endpointMatches(packet.src_ip, packet.src_mac, conversation.src_ip_address, conversation.src_mac_address) &&
|
||||
endpointMatches(packet.dst_ip, packet.dst_mac, conversation.dst_ip_address, conversation.dst_mac_address) &&
|
||||
(conversation.src_port == null || packet.src_port === conversation.src_port) &&
|
||||
(conversation.dst_port == null || packet.dst_port === conversation.dst_port);
|
||||
if (forward) return 'forward';
|
||||
|
||||
const reverse =
|
||||
endpointMatches(packet.src_ip, packet.src_mac, conversation.dst_ip_address, conversation.dst_mac_address) &&
|
||||
endpointMatches(packet.dst_ip, packet.dst_mac, conversation.src_ip_address, conversation.src_mac_address) &&
|
||||
(conversation.src_port == null || packet.dst_port === conversation.src_port) &&
|
||||
(conversation.dst_port == null || packet.src_port === conversation.dst_port);
|
||||
if (reverse) return 'reverse';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function renderLabelTags(values: string[], color = 'default') {
|
||||
if (values.length === 0) return '—';
|
||||
return (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{values.map((value) => (
|
||||
<Tag key={value} color={color}>
|
||||
{value}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderLabelCountTags(values: LabelCountEvidence[]) {
|
||||
if (values.length === 0) return '—';
|
||||
return (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{values.map((value) => (
|
||||
<Tag key={value.label} color={protocolColor(value.label)}>
|
||||
{value.label}: {value.packet_count}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export function hostIdentity(host: InterfaceHostProtocolEvidence) {
|
||||
return `${host.ip_address ?? 'no-ip'}|${host.mac_address ?? 'no-mac'}`;
|
||||
}
|
||||
|
||||
export function hostLabel(interfaceName: string, host: InterfaceHostProtocolEvidence) {
|
||||
const ip = host.ip_address ?? 'unknown ip';
|
||||
const mac = host.mac_address ?? 'unknown mac';
|
||||
return `${interfaceName} • ${ip}\n${mac}`;
|
||||
}
|
||||
|
||||
export function protocolColor(protocol: string) {
|
||||
const palette = d3.schemeTableau10;
|
||||
let hash = 0;
|
||||
for (let index = 0; index < protocol.length; index += 1) {
|
||||
hash = (hash * 31 + protocol.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return palette[hash % palette.length];
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export type ChartKind = 'sankey' | 'parallel' | 'force' | 'heatmap' | 'matrix' | 'timeline' | 'sequence';
|
||||
|
||||
// Single place to tune overall Analysis visualization sizing.
|
||||
export const ANALYSIS_VISUAL_SCALE = 1;
|
||||
|
||||
// Per-visualization tuning layered on top of the global Analysis scale.
|
||||
export const ANALYSIS_VISUAL_SCALE_RATES: Record<ChartKind, number> = {
|
||||
sankey: 1.5,
|
||||
parallel: 1.5,
|
||||
force: 1.5,
|
||||
heatmap: 1,
|
||||
matrix: 1.2,
|
||||
timeline: 1.5,
|
||||
sequence: 1.2,
|
||||
};
|
||||
|
||||
export function scaleVisual(value: number) {
|
||||
return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE));
|
||||
}
|
||||
|
||||
export function scaleVisualFor(kind: ChartKind, value: number) {
|
||||
return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE * ANALYSIS_VISUAL_SCALE_RATES[kind]));
|
||||
}
|
||||
|
||||
export function useResponsiveChartWidth() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const measureWidth = () => {
|
||||
let width = container.getBoundingClientRect().width;
|
||||
let ancestor = container.parentElement;
|
||||
let depth = 0;
|
||||
while (ancestor != null && depth < 3) {
|
||||
width = Math.max(width, ancestor.getBoundingClientRect().width);
|
||||
ancestor = ancestor.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
const updateSize = () => {
|
||||
const width = measureWidth();
|
||||
setViewportWidth(width > 0 ? Math.floor(width) : 0);
|
||||
};
|
||||
|
||||
updateSize();
|
||||
|
||||
const observer = new ResizeObserver(() => updateSize());
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { containerRef, viewportWidth };
|
||||
}
|
||||
|
||||
type ChartPolicy = {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxHeightPx: number;
|
||||
maxHeightVh: number;
|
||||
};
|
||||
|
||||
const chartPolicies: Record<ChartKind, ChartPolicy> = {
|
||||
sankey: {
|
||||
minWidth: scaleVisualFor('sankey', 560),
|
||||
minHeight: scaleVisualFor('sankey', 230),
|
||||
maxHeightPx: scaleVisualFor('sankey', 600),
|
||||
maxHeightVh: 0.62,
|
||||
},
|
||||
parallel: {
|
||||
minWidth: scaleVisualFor('parallel', 660),
|
||||
minHeight: scaleVisualFor('parallel', 380),
|
||||
maxHeightPx: scaleVisualFor('parallel', 740),
|
||||
maxHeightVh: 0.72,
|
||||
},
|
||||
force: {
|
||||
minWidth: scaleVisualFor('force', 660),
|
||||
minHeight: scaleVisualFor('force', 340),
|
||||
maxHeightPx: scaleVisualFor('force', 580),
|
||||
maxHeightVh: 0.62,
|
||||
},
|
||||
heatmap: {
|
||||
minWidth: scaleVisualFor('heatmap', 660),
|
||||
minHeight: scaleVisualFor('heatmap', 320),
|
||||
maxHeightPx: scaleVisualFor('heatmap', 600),
|
||||
maxHeightVh: 0.62,
|
||||
},
|
||||
matrix: {
|
||||
minWidth: scaleVisualFor('matrix', 660),
|
||||
minHeight: scaleVisualFor('matrix', 320),
|
||||
maxHeightPx: scaleVisualFor('matrix', 600),
|
||||
maxHeightVh: 0.62,
|
||||
},
|
||||
timeline: {
|
||||
minWidth: scaleVisualFor('timeline', 740),
|
||||
minHeight: scaleVisualFor('timeline', 300),
|
||||
maxHeightPx: scaleVisualFor('timeline', 620),
|
||||
maxHeightVh: 0.66,
|
||||
},
|
||||
sequence: {
|
||||
minWidth: scaleVisualFor('sequence', 740),
|
||||
minHeight: scaleVisualFor('sequence', 340),
|
||||
maxHeightPx: scaleVisualFor('sequence', 780),
|
||||
maxHeightVh: 0.76,
|
||||
},
|
||||
};
|
||||
|
||||
type ChartSizeOverrides = Partial<ChartPolicy>;
|
||||
|
||||
function resolveChartMaxHeight(viewportHeight: number, minHeight: number, maxHeightPx: number, maxHeightVh: number) {
|
||||
const viewportCap = viewportHeight > 0 ? Math.floor(viewportHeight * maxHeightVh) : maxHeightPx;
|
||||
return Math.min(maxHeightPx, Math.max(minHeight, viewportCap));
|
||||
}
|
||||
|
||||
export function useResponsiveChartSize(kind: ChartKind, overrides: ChartSizeOverrides = {}) {
|
||||
const { containerRef, viewportWidth } = useResponsiveChartWidth();
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportHeight = () => {
|
||||
const height = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
setViewportHeight(height > 0 ? Math.floor(height) : 0);
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('resize', updateViewportHeight);
|
||||
return () => window.removeEventListener('resize', updateViewportHeight);
|
||||
}, []);
|
||||
|
||||
const policy = { ...chartPolicies[kind], ...overrides };
|
||||
const chartWidth = viewportWidth > 0 ? Math.floor(viewportWidth) : policy.minWidth;
|
||||
const maxChartHeight = resolveChartMaxHeight(
|
||||
viewportHeight,
|
||||
policy.minHeight,
|
||||
policy.maxHeightPx,
|
||||
policy.maxHeightVh,
|
||||
);
|
||||
|
||||
const fitHeight = (desiredHeight: number, localOverrides: ChartSizeOverrides = {}) => {
|
||||
const localMinHeight = localOverrides.minHeight ?? policy.minHeight;
|
||||
const localMaxHeight = resolveChartMaxHeight(
|
||||
viewportHeight,
|
||||
localMinHeight,
|
||||
localOverrides.maxHeightPx ?? policy.maxHeightPx,
|
||||
localOverrides.maxHeightVh ?? policy.maxHeightVh,
|
||||
);
|
||||
return clamp(desiredHeight, localMinHeight, localMaxHeight);
|
||||
};
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
chartWidth,
|
||||
minChartHeight: policy.minHeight,
|
||||
maxChartHeight,
|
||||
fitHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function sankeyVisualWeight(packetCount: number) {
|
||||
return Math.max(1, Math.sqrt(Math.max(0, packetCount)));
|
||||
}
|
||||
|
||||
function addOrUpdateLink(
|
||||
links: Map<string, TopologyLink>,
|
||||
source: string,
|
||||
target: string,
|
||||
packetCount: number,
|
||||
label: string,
|
||||
) {
|
||||
const linkId = `${source}->${target}`;
|
||||
const existing = links.get(linkId);
|
||||
if (existing) {
|
||||
existing.packetCount += packetCount;
|
||||
existing.value = existing.packetCount;
|
||||
existing.label = `${existing.label.split(' (')[0]} (${existing.packetCount})`;
|
||||
return;
|
||||
}
|
||||
links.set(linkId, {
|
||||
source,
|
||||
target,
|
||||
value: packetCount,
|
||||
packetCount,
|
||||
label: `${label} (${packetCount})`,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureProtocolNode(nodes: Map<string, TopologyNode>, id: string, label: string, kind: TopologyLayerKind) {
|
||||
if (!nodes.has(id)) {
|
||||
nodes.set(id, {
|
||||
id,
|
||||
label,
|
||||
kind,
|
||||
packetCount: 0,
|
||||
protocol: label,
|
||||
});
|
||||
}
|
||||
return nodes.get(id)!;
|
||||
}
|
||||
|
||||
export function buildTopologyData(interfaces: InterfaceProtocolAttachment[], options: TopologyOptions): TopologyData {
|
||||
const nodes = new Map<string, TopologyNode>();
|
||||
const links = new Map<string, TopologyLink>();
|
||||
const heatmapByHost = new Map<string, HeatmapRow>();
|
||||
const protocols = new Set<string>();
|
||||
const tableRows: ProtocolTableRow[] = [];
|
||||
|
||||
for (const entry of interfaces) {
|
||||
const interfaceNodeId = `iface:${entry.interface}`;
|
||||
nodes.set(interfaceNodeId, {
|
||||
id: interfaceNodeId,
|
||||
label: entry.interface,
|
||||
kind: 'interface',
|
||||
packetCount: entry.hosts.reduce((sum, host) => sum + host.packet_count, 0),
|
||||
interfaceName: entry.interface,
|
||||
});
|
||||
|
||||
for (const host of entry.hosts) {
|
||||
const hostId = `host:${entry.interface}:${hostIdentity(host)}`;
|
||||
nodes.set(hostId, {
|
||||
id: hostId,
|
||||
label: hostLabel(entry.interface, host),
|
||||
kind: 'host',
|
||||
packetCount: host.packet_count,
|
||||
interfaceName: entry.interface,
|
||||
ipAddress: host.ip_address,
|
||||
macAddress: host.mac_address,
|
||||
});
|
||||
|
||||
const interfaceHostLinkId = `${interfaceNodeId}->${hostId}`;
|
||||
links.set(interfaceHostLinkId, {
|
||||
source: interfaceNodeId,
|
||||
target: hostId,
|
||||
value: host.packet_count,
|
||||
packetCount: host.packet_count,
|
||||
label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`,
|
||||
});
|
||||
|
||||
const heatmapRow: HeatmapRow = {
|
||||
hostId,
|
||||
hostLabel: `${entry.interface} • ${host.ip_address ?? 'unknown ip'}`,
|
||||
interfaceName: entry.interface,
|
||||
values: {},
|
||||
};
|
||||
|
||||
for (const protocol of host.protocols) {
|
||||
protocols.add(protocol.protocol);
|
||||
const layerPaths =
|
||||
protocol.layer_paths.length > 0
|
||||
? protocol.layer_paths
|
||||
: [
|
||||
{
|
||||
ethernet_protocol: protocol.ethernet_protocol ?? null,
|
||||
ip_protocol: protocol.ip_protocol ?? null,
|
||||
packet_count: protocol.packet_count,
|
||||
last_seen: protocol.last_seen,
|
||||
accept_count: protocol.accept_count,
|
||||
drop_count: protocol.drop_count,
|
||||
reject_count: protocol.reject_count,
|
||||
unknown_count: protocol.unknown_count,
|
||||
},
|
||||
];
|
||||
|
||||
for (const layerPath of layerPaths) {
|
||||
let currentNodeId = hostId;
|
||||
let currentLabel = host.ip_address ?? host.mac_address ?? 'host';
|
||||
|
||||
if (
|
||||
options.includeEthernetLayer &&
|
||||
layerPath.ethernet_protocol &&
|
||||
layerPath.ethernet_protocol !== protocol.protocol
|
||||
) {
|
||||
const ethernetId = `ethernet:${layerPath.ethernet_protocol}`;
|
||||
const ethernetNode = ensureProtocolNode(nodes, ethernetId, layerPath.ethernet_protocol, 'ethernet');
|
||||
ethernetNode.packetCount += layerPath.packet_count;
|
||||
addOrUpdateLink(
|
||||
links,
|
||||
currentNodeId,
|
||||
ethernetId,
|
||||
layerPath.packet_count,
|
||||
`${currentLabel} -> ${layerPath.ethernet_protocol}`,
|
||||
);
|
||||
currentNodeId = ethernetId;
|
||||
currentLabel = layerPath.ethernet_protocol;
|
||||
}
|
||||
|
||||
if (
|
||||
options.includeIpLayer &&
|
||||
layerPath.ip_protocol &&
|
||||
layerPath.ip_protocol !== currentLabel &&
|
||||
layerPath.ip_protocol !== protocol.protocol
|
||||
) {
|
||||
const ipId = `ip:${layerPath.ip_protocol}`;
|
||||
const ipNode = ensureProtocolNode(nodes, ipId, layerPath.ip_protocol, 'ip');
|
||||
ipNode.packetCount += layerPath.packet_count;
|
||||
addOrUpdateLink(
|
||||
links,
|
||||
currentNodeId,
|
||||
ipId,
|
||||
layerPath.packet_count,
|
||||
`${currentLabel} -> ${layerPath.ip_protocol}`,
|
||||
);
|
||||
currentNodeId = ipId;
|
||||
currentLabel = layerPath.ip_protocol;
|
||||
}
|
||||
|
||||
if (currentLabel !== protocol.protocol || currentNodeId === hostId) {
|
||||
const protocolId = `protocol:${protocol.protocol}`;
|
||||
const protocolNode = ensureProtocolNode(nodes, protocolId, protocol.protocol, 'protocol');
|
||||
protocolNode.packetCount += layerPath.packet_count;
|
||||
addOrUpdateLink(
|
||||
links,
|
||||
currentNodeId,
|
||||
protocolId,
|
||||
layerPath.packet_count,
|
||||
`${currentLabel} -> ${protocol.protocol}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
heatmapRow.values[protocol.protocol] = protocol.packet_count;
|
||||
tableRows.push({
|
||||
key: `${entry.interface}-${hostIdentity(host)}-${protocol.protocol}`,
|
||||
interface: entry.interface,
|
||||
ip_address: host.ip_address,
|
||||
mac_address: host.mac_address,
|
||||
host_packet_count: host.packet_count,
|
||||
protocol: protocol.protocol,
|
||||
protocol_packet_count: protocol.packet_count,
|
||||
accept_count: protocol.accept_count,
|
||||
drop_count: protocol.drop_count,
|
||||
reject_count: protocol.reject_count,
|
||||
unknown_count: protocol.unknown_count,
|
||||
last_seen: protocol.last_seen,
|
||||
});
|
||||
}
|
||||
|
||||
heatmapByHost.set(hostId, heatmapRow);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodes.values()),
|
||||
links: Array.from(links.values()),
|
||||
heatmapRows: Array.from(heatmapByHost.values()).sort((left, right) =>
|
||||
left.hostLabel.localeCompare(right.hostLabel),
|
||||
),
|
||||
protocols: Array.from(protocols).sort(),
|
||||
tableRows: tableRows.sort(
|
||||
(left, right) =>
|
||||
right.protocol_packet_count - left.protocol_packet_count || left.interface.localeCompare(right.interface),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDirectionalSankeyData(
|
||||
paths: InterfaceProtocolPathEvidence[],
|
||||
options: TopologyOptions,
|
||||
): TopologyData {
|
||||
const nodes = new Map<string, TopologyNode>();
|
||||
const links = new Map<string, TopologyLink>();
|
||||
|
||||
for (const path of paths) {
|
||||
const packetCount = path.packet_count;
|
||||
const ingressLabel = path.ingress_interface ? `${path.ingress_interface} (ingress)` : 'Unknown ingress';
|
||||
const ingressId = `ingress:${path.ingress_interface ?? 'unknown'}`;
|
||||
const sourceLabel = endpointText(path.src_ip_address, path.src_mac_address);
|
||||
const sourceId = `source:${path.src_ip_address ?? 'no-ip'}|${path.src_mac_address ?? 'no-mac'}`;
|
||||
const protocolLabel = path.protocol;
|
||||
const protocolId = `protocol:${protocolLabel}`;
|
||||
const destinationLabel = endpointText(path.dst_ip_address, path.dst_mac_address);
|
||||
const destinationId = `destination:${path.dst_ip_address ?? 'no-ip'}|${path.dst_mac_address ?? 'no-mac'}`;
|
||||
const egressLabel = path.egress_interface ? `${path.egress_interface} (egress)` : 'Unknown egress';
|
||||
const egressId = `egress:${path.egress_interface ?? 'unknown'}`;
|
||||
|
||||
ensureProtocolNode(nodes, ingressId, ingressLabel, 'interface').packetCount += packetCount;
|
||||
nodes.set(sourceId, {
|
||||
...(nodes.get(sourceId) ?? {
|
||||
id: sourceId,
|
||||
label: sourceLabel,
|
||||
kind: 'host' as const,
|
||||
packetCount: 0,
|
||||
ipAddress: path.src_ip_address,
|
||||
macAddress: path.src_mac_address,
|
||||
}),
|
||||
packetCount: (nodes.get(sourceId)?.packetCount ?? 0) + packetCount,
|
||||
});
|
||||
ensureProtocolNode(nodes, protocolId, protocolLabel, 'protocol').packetCount += packetCount;
|
||||
nodes.set(destinationId, {
|
||||
...(nodes.get(destinationId) ?? {
|
||||
id: destinationId,
|
||||
label: destinationLabel,
|
||||
kind: 'host' as const,
|
||||
packetCount: 0,
|
||||
ipAddress: path.dst_ip_address,
|
||||
macAddress: path.dst_mac_address,
|
||||
}),
|
||||
packetCount: (nodes.get(destinationId)?.packetCount ?? 0) + packetCount,
|
||||
});
|
||||
ensureProtocolNode(nodes, egressId, egressLabel, 'interface').packetCount += packetCount;
|
||||
|
||||
addOrUpdateLink(links, sourceId, ingressId, packetCount, `${sourceLabel} -> ${ingressLabel}`);
|
||||
|
||||
let currentNodeId = ingressId;
|
||||
let currentLabel = ingressLabel;
|
||||
|
||||
if (options.includeEthernetLayer && path.ethernet_protocol && path.ethernet_protocol !== protocolLabel) {
|
||||
const ethernetId = `ethernet:${path.ethernet_protocol}`;
|
||||
ensureProtocolNode(nodes, ethernetId, path.ethernet_protocol, 'ethernet').packetCount += packetCount;
|
||||
addOrUpdateLink(links, currentNodeId, ethernetId, packetCount, `${currentLabel} -> ${path.ethernet_protocol}`);
|
||||
currentNodeId = ethernetId;
|
||||
currentLabel = path.ethernet_protocol;
|
||||
}
|
||||
|
||||
if (
|
||||
options.includeIpLayer &&
|
||||
path.ip_protocol &&
|
||||
path.ip_protocol !== currentLabel &&
|
||||
path.ip_protocol !== protocolLabel
|
||||
) {
|
||||
const ipId = `ip:${path.ip_protocol}`;
|
||||
ensureProtocolNode(nodes, ipId, path.ip_protocol, 'ip').packetCount += packetCount;
|
||||
addOrUpdateLink(links, currentNodeId, ipId, packetCount, `${currentLabel} -> ${path.ip_protocol}`);
|
||||
currentNodeId = ipId;
|
||||
currentLabel = path.ip_protocol;
|
||||
}
|
||||
|
||||
addOrUpdateLink(links, currentNodeId, protocolId, packetCount, `${currentLabel} -> ${protocolLabel}`);
|
||||
addOrUpdateLink(links, protocolId, egressId, packetCount, `${protocolLabel} -> ${egressLabel}`);
|
||||
addOrUpdateLink(links, egressId, destinationId, packetCount, `${egressLabel} -> ${destinationLabel}`);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodes.values()),
|
||||
links: Array.from(links.values()),
|
||||
heatmapRows: [],
|
||||
protocols: [],
|
||||
tableRows: [],
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/hooks/useNetwork.ts
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import * as api from "../api/apiClient";
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import * as api from '../api/apiClient';
|
||||
import type {
|
||||
BridgeCreateRequest,
|
||||
BridgeInfo,
|
||||
@@ -9,24 +9,15 @@ import type {
|
||||
FullState,
|
||||
InterfaceInfo,
|
||||
RouteInfo,
|
||||
} from "../types/network";
|
||||
import { SnifferStatusResponse } from "../types/sniffer";
|
||||
} from '../types/network';
|
||||
import { SnifferStatusResponse } from '../types/sniffer';
|
||||
|
||||
const TEN_SECONDS = 1000 * 10;
|
||||
const FIVE_SECONDS = 1000 * 5;
|
||||
|
||||
/**
|
||||
* useNetwork
|
||||
*
|
||||
* - queries start disabled (no automatic network calls)
|
||||
* - calling fetchInterfaces()/fetchBridges()/... will:
|
||||
* 1) fetch and cache the data right away (queryClient.fetchQuery)
|
||||
* 2) enable the corresponding useQuery so it becomes "active" and will
|
||||
* auto-refetch based on the query options
|
||||
*
|
||||
* This gives "no initial auto-fetch" but "once fetched, auto-updates".
|
||||
*/
|
||||
export function useBackendAPI() {
|
||||
const qc = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// per-query enabled flags (start false => no automatic fetch)
|
||||
const [interfacesEnabled, setInterfacesEnabled] = useState(false);
|
||||
const [linksEnabled, setLinksEnabled] = useState(false);
|
||||
const [routesEnabled, setRoutesEnabled] = useState(false);
|
||||
@@ -34,132 +25,126 @@ export function useBackendAPI() {
|
||||
const [fullStateEnabled, setFullStateEnabled] = useState(false);
|
||||
const [snifferStatusEnabled, setSnifferStatusEnabled] = useState(false);
|
||||
|
||||
// common query options once enabled
|
||||
const commonOptions = {
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 1000 * 10, // 10s
|
||||
staleTime: TEN_SECONDS,
|
||||
};
|
||||
|
||||
// Queries (disabled initially)
|
||||
const interfacesQuery = useQuery<InterfaceInfo[]>({
|
||||
queryKey: ["interfaces"],
|
||||
queryKey: ['interfaces'],
|
||||
queryFn: api.fetchInterfaces,
|
||||
enabled: interfacesEnabled,
|
||||
...commonOptions,
|
||||
});
|
||||
|
||||
const linksQuery = useQuery<InterfaceInfo[]>({
|
||||
queryKey: ["links"],
|
||||
queryKey: ['links'],
|
||||
queryFn: api.fetchLinks,
|
||||
enabled: linksEnabled,
|
||||
...commonOptions,
|
||||
});
|
||||
|
||||
const routesQuery = useQuery<RouteInfo[]>({
|
||||
queryKey: ["routes"],
|
||||
queryKey: ['routes'],
|
||||
queryFn: api.fetchRoutes,
|
||||
enabled: routesEnabled,
|
||||
...commonOptions,
|
||||
});
|
||||
|
||||
const bridgesQuery = useQuery<BridgeInfo[]>({
|
||||
queryKey: ["bridges"],
|
||||
queryKey: ['bridges'],
|
||||
queryFn: api.fetchBridges,
|
||||
enabled: bridgesEnabled,
|
||||
...commonOptions,
|
||||
});
|
||||
|
||||
const fullStateQuery = useQuery<FullState>({
|
||||
queryKey: ["full-state"],
|
||||
queryKey: ['full-state'],
|
||||
queryFn: api.fetchFullState,
|
||||
enabled: fullStateEnabled,
|
||||
...commonOptions,
|
||||
});
|
||||
|
||||
const snifferStatusQuery = useQuery<SnifferStatusResponse>({
|
||||
queryKey: ["sniffer-status"],
|
||||
queryKey: ['sniffer-status'],
|
||||
queryFn: api.fetchSnifferStatus,
|
||||
enabled: fullStateEnabled,
|
||||
enabled: snifferStatusEnabled,
|
||||
...commonOptions,
|
||||
});
|
||||
|
||||
// Imperative fetch helpers that also enable auto-refetch behavior
|
||||
const fetchInterfaces = useCallback(async () => {
|
||||
const res = await qc.fetchQuery<InterfaceInfo[]>({
|
||||
queryKey: ["interfaces"],
|
||||
const result = await queryClient.fetchQuery<InterfaceInfo[]>({
|
||||
queryKey: ['interfaces'],
|
||||
queryFn: api.fetchInterfaces,
|
||||
staleTime: 1000 * 10
|
||||
staleTime: TEN_SECONDS,
|
||||
});
|
||||
setInterfacesEnabled(true);
|
||||
return res;
|
||||
}, [qc]);
|
||||
return result;
|
||||
}, [queryClient]);
|
||||
|
||||
const fetchLinks = useCallback(async () => {
|
||||
const res = await qc.fetchQuery<InterfaceInfo[]>({
|
||||
queryKey: ["links"],
|
||||
const result = await queryClient.fetchQuery<InterfaceInfo[]>({
|
||||
queryKey: ['links'],
|
||||
queryFn: api.fetchLinks,
|
||||
staleTime: 1000 * 10
|
||||
staleTime: TEN_SECONDS,
|
||||
});
|
||||
setLinksEnabled(true);
|
||||
return res;
|
||||
}, [qc]);
|
||||
return result;
|
||||
}, [queryClient]);
|
||||
|
||||
const fetchRoutes = useCallback(async () => {
|
||||
const res = await qc.fetchQuery<RouteInfo[]>({
|
||||
queryKey: ["routes"],
|
||||
const result = await queryClient.fetchQuery<RouteInfo[]>({
|
||||
queryKey: ['routes'],
|
||||
queryFn: api.fetchRoutes,
|
||||
staleTime: 1000 * 10
|
||||
staleTime: TEN_SECONDS,
|
||||
});
|
||||
setRoutesEnabled(true);
|
||||
return res;
|
||||
}, [qc]);
|
||||
return result;
|
||||
}, [queryClient]);
|
||||
|
||||
const fetchBridges = useCallback(async () => {
|
||||
const res = await qc.fetchQuery<BridgeInfo[]>({
|
||||
queryKey: ["bridges"],
|
||||
const result = await queryClient.fetchQuery<BridgeInfo[]>({
|
||||
queryKey: ['bridges'],
|
||||
queryFn: api.fetchBridges,
|
||||
staleTime: 1000 * 10
|
||||
staleTime: TEN_SECONDS,
|
||||
});
|
||||
setBridgesEnabled(true);
|
||||
return res;
|
||||
}, [qc]);
|
||||
return result;
|
||||
}, [queryClient]);
|
||||
|
||||
const fetchFullState = useCallback(async () => {
|
||||
const res = await qc.fetchQuery<FullState>({
|
||||
queryKey: ["full-state"],
|
||||
const result = await queryClient.fetchQuery<FullState>({
|
||||
queryKey: ['full-state'],
|
||||
queryFn: api.fetchFullState,
|
||||
staleTime: 1000 * 5
|
||||
staleTime: FIVE_SECONDS,
|
||||
});
|
||||
setFullStateEnabled(true);
|
||||
return res;
|
||||
}, [qc]);
|
||||
return result;
|
||||
}, [queryClient]);
|
||||
|
||||
const fetchSnifferStatus = useCallback(async () => {
|
||||
const res = await qc.fetchQuery<SnifferStatusResponse>({
|
||||
queryKey: ["sniffer-status"],
|
||||
const result = await queryClient.fetchQuery<SnifferStatusResponse>({
|
||||
queryKey: ['sniffer-status'],
|
||||
queryFn: api.fetchSnifferStatus,
|
||||
staleTime: 1000 * 5
|
||||
staleTime: FIVE_SECONDS,
|
||||
});
|
||||
setFullStateEnabled(true);
|
||||
return res;
|
||||
}, [qc]);
|
||||
setSnifferStatusEnabled(true);
|
||||
return result;
|
||||
}, [queryClient]);
|
||||
|
||||
// Local loading state for simple UI feedback
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
// Simple imperative functions that call the API and invalidate queries
|
||||
async function createBridge(payload: BridgeCreateRequest) {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await api.createBridge(payload);
|
||||
// If the query is enabled it will refetch automatically after invalidation.
|
||||
await qc.invalidateQueries({ queryKey: ["bridges"] });
|
||||
await qc.invalidateQueries({ queryKey: ["full-state"] });
|
||||
await qc.invalidateQueries({ queryKey: ["interfaces"] });
|
||||
} catch (err) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['bridges'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['full-state'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
|
||||
} catch (error) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "Create bridge failed";
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Create bridge failed';
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -170,45 +155,43 @@ export function useBackendAPI() {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await api.removeBridge(payload);
|
||||
await qc.invalidateQueries({ queryKey: ["bridges"] });
|
||||
await qc.invalidateQueries({ queryKey: ["full-state"] });
|
||||
await qc.invalidateQueries({ queryKey: ["interfaces"] });
|
||||
await qc.invalidateQueries({ queryKey: ["sniffer-status"] });
|
||||
} catch (err) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['bridges'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['full-state'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
|
||||
} catch (error) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "Remove bridge failed";
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Remove bridge failed';
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience: invalidate helpers
|
||||
function refreshInterfaces() {
|
||||
return qc.invalidateQueries({ queryKey: ["interfaces"] });
|
||||
return queryClient.invalidateQueries({ queryKey: ['interfaces'] });
|
||||
}
|
||||
|
||||
function refreshLinks() {
|
||||
return qc.invalidateQueries({ queryKey: ["links"] });
|
||||
return queryClient.invalidateQueries({ queryKey: ['links'] });
|
||||
}
|
||||
|
||||
function refreshRoutes() {
|
||||
return qc.invalidateQueries({ queryKey: ["routes"] });
|
||||
return queryClient.invalidateQueries({ queryKey: ['routes'] });
|
||||
}
|
||||
|
||||
function refreshBridges() {
|
||||
return qc.invalidateQueries({ queryKey: ["bridges"] });
|
||||
return queryClient.invalidateQueries({ queryKey: ['bridges'] });
|
||||
}
|
||||
|
||||
function refreshFullState() {
|
||||
return qc.invalidateQueries({ queryKey: ["full-state"] });
|
||||
return queryClient.invalidateQueries({ queryKey: ['full-state'] });
|
||||
}
|
||||
|
||||
function refreshSnifferStatus() {
|
||||
return qc.invalidateQueries({ queryKey: ["sniffer-status"] });
|
||||
return queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
|
||||
}
|
||||
|
||||
// Convenience: refresh all queries
|
||||
function refreshAll() {
|
||||
refreshInterfaces();
|
||||
refreshLinks();
|
||||
@@ -219,39 +202,28 @@ export function useBackendAPI() {
|
||||
}
|
||||
|
||||
return {
|
||||
// queries
|
||||
interfacesQuery,
|
||||
linksQuery,
|
||||
routesQuery,
|
||||
bridgesQuery,
|
||||
fullStateQuery,
|
||||
snifferStatusQuery,
|
||||
|
||||
// manual fetchers (fetch+enable auto-updates)
|
||||
fetchInterfaces,
|
||||
fetchLinks,
|
||||
fetchRoutes,
|
||||
fetchBridges,
|
||||
fetchFullState,
|
||||
fetchSnifferStatus,
|
||||
|
||||
// simple mutation functions (imperative)
|
||||
createBridge,
|
||||
removeBridge,
|
||||
|
||||
// local loading flags
|
||||
isCreating,
|
||||
isRemoving,
|
||||
|
||||
// invalidate helpers
|
||||
refreshInterfaces,
|
||||
refreshLinks,
|
||||
refreshRoutes,
|
||||
refreshBridges,
|
||||
refreshFullState,
|
||||
refreshSnifferStatus,
|
||||
|
||||
// refresh all
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
|
||||
59
frontend/src/icons/FirewallIcon.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export type IconProps = React.SVGProps<SVGSVGElement> & {
|
||||
/**
|
||||
* Width/height of the icon. If a number is provided it will be used as px.
|
||||
* Default: 24
|
||||
*/
|
||||
size?: number | string;
|
||||
/**
|
||||
* Icon color — will be used as the fill for paths.
|
||||
* Default: 'currentColor' so color can be controlled via CSS.
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Accessible title. If provided, title will be rendered and aria-hidden will be false.
|
||||
*/
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const FirewallIcon = forwardRef<SVGSVGElement, IconProps>(
|
||||
({ size = 20, color = 'currentColor', title, ...rest }, ref) => {
|
||||
// If user passed a numeric size, treat as px
|
||||
const sizeValue = typeof size === 'number' ? `${size}px` : size;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={sizeValue}
|
||||
height={sizeValue}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
>
|
||||
<path
|
||||
d="M17 12C17 12.6566 16.8707 13.3068 16.6194 13.9134C16.3681 14.52 15.9998 15.0712 15.5355 15.5355C15.0712 15.9998 14.52 16.3681 13.9134 16.6194C13.3068 16.8707 12.6566 17 12 17C11.3434 17 10.6932 16.8707 10.0866 16.6194C9.47995 16.3681 8.92876 15.9998 8.46447 15.5355C8.00017 15.0712 7.63188 14.52 7.3806 13.9134C7.12933 13.3068 7 12.6566 7 12C7 11.3434 7.12933 10.6932 7.3806 10.0866C7.63188 9.47995 8.00017 8.92876 8.46447 8.46447C8.92876 8.00017 9.47996 7.63188 10.0866 7.3806C10.6932 7.12933 11.3434 7 12 7C12.6566 7 13.3068 7.12933 13.9134 7.3806C14.52 7.63188 15.0712 8.00017 15.5355 8.46447C15.9998 8.92876 16.3681 9.47996 16.6194 10.0866C16.8707 10.6932 17 11.3434 17 12L17 12Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M13.8478 13.9134C13.9483 13.3068 14 12.6566 14 12C14 11.3434 13.9483 10.6932 13.8478 10.0866C13.7472 9.47996 13.5999 8.92876 13.4142 8.46447C13.2285 8.00017 13.008 7.63188 12.7654 7.3806C12.5227 7.12933 12.2626 7 12 7C11.7374 7 11.4773 7.12933 11.2346 7.3806C10.992 7.63188 10.7715 8.00017 10.5858 8.46447C10.4001 8.92876 10.2528 9.47995 10.1522 10.0866C10.0517 10.6932 10 11.3434 10 12C10 12.6566 10.0517 13.3068 10.1522 13.9134C10.2527 14.52 10.4001 15.0712 10.5858 15.5355C10.7715 15.9998 10.992 16.3681 11.2346 16.6194C11.4773 16.8707 11.7374 17 12 17C12.2626 17 12.5227 16.8707 12.7654 16.6194C13.008 16.3681 13.2285 15.9998 13.4142 15.5355C13.5999 15.0712 13.7472 14.52 13.8478 13.9134Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M7 12H17" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path
|
||||
d="M3 10.4167C3 7.21907 3 5.62028 3.37752 5.08241C3.75503 4.54454 5.25832 4.02996 8.26491 3.00079L8.83772 2.80472C10.405 2.26824 11.1886 2 12 2C12.8114 2 13.595 2.26824 15.1623 2.80472L15.7351 3.00079C18.7417 4.02996 20.245 4.54454 20.6225 5.08241C21 5.62028 21 7.21907 21 10.4167C21 10.8996 21 11.4234 21 11.9914C21 14.4963 20.1632 16.4284 19 17.9041M3.19284 14C4.05026 18.2984 7.57641 20.5129 9.89856 21.5273C10.62 21.8424 10.9807 22 12 22C13.0193 22 13.38 21.8424 14.1014 21.5273C14.6796 21.2747 15.3324 20.9478 16 20.5328"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FirewallIcon.displayName = 'FirewallIcon';
|
||||
|
||||
export default FirewallIcon;
|
||||
@@ -14,7 +14,7 @@ html {
|
||||
|
||||
/* Root background and typography (matches theme background + font) */
|
||||
body {
|
||||
font-family: "Inter", "Roboto", "Helvetica", "Arial", sans-serif;
|
||||
font-family: 'Inter', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
background-color: #f9f9f9; /* matches theme.palette.background.default */
|
||||
color: #262626; /* matches theme.palette.text.primary */
|
||||
line-height: 1.6;
|
||||
@@ -70,13 +70,15 @@ a:hover {
|
||||
.fade-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
}
|
||||
|
||||
/* === Code blocks / preformatted text === */
|
||||
pre,
|
||||
code {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* === App container fix for fixed header === */
|
||||
|
||||
582
frontend/src/pages/Analysis.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
InputNumber,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
fetchConversationAnalysis,
|
||||
fetchConversationFlowDetail,
|
||||
fetchHostIntelligenceAnalysis,
|
||||
fetchInterfaceHostProtocolAnalysis,
|
||||
fetchInterfaceProtocolPathAnalysis,
|
||||
} from '../api/apiClient';
|
||||
import {
|
||||
ConversationFlowDrawer,
|
||||
ConversationMatrix,
|
||||
ConversationTimeline,
|
||||
} from '../components/analysis/CommunicationViews';
|
||||
import {
|
||||
HostDetailDrawer,
|
||||
} from '../components/analysis/IntelligenceRiskViews';
|
||||
import { ForceTopology, PacketPathLanes, ProtocolHeatmap, SankeyTopology } from '../components/analysis/TopologyViews';
|
||||
import {
|
||||
buildTopologyData,
|
||||
conversationRowKey,
|
||||
endpointText,
|
||||
formatBytes,
|
||||
formatDurationMs,
|
||||
formatTimestamp,
|
||||
protocolColor,
|
||||
renderLabelCountTags,
|
||||
renderLabelTags,
|
||||
type ProtocolTableRow,
|
||||
} from '../components/analysis/shared.tsx';
|
||||
import type {
|
||||
ConversationAnalysisResponse,
|
||||
ConversationEvidence,
|
||||
ConversationFlowDetailResponse,
|
||||
HostIntelligenceAnalysisResponse,
|
||||
HostIntelligenceEvidence,
|
||||
InterfaceHostProtocolAnalysisResponse,
|
||||
InterfaceProtocolPathAnalysisResponse,
|
||||
} from '../types/analysis';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function Analysis(): ReactElement {
|
||||
const [sinceMinutes, setSinceMinutes] = useState<number | null>(null);
|
||||
const [limitPerInterface, setLimitPerInterface] = useState(50);
|
||||
const [limitProtocolsPerHost, setLimitProtocolsPerHost] = useState(12);
|
||||
const [limitPaths, setLimitPaths] = useState(500);
|
||||
const [limitConversations, setLimitConversations] = useState(300);
|
||||
const [limitHostIntelligence, setLimitHostIntelligence] = useState(40);
|
||||
const [includeEthernetLayer, setIncludeEthernetLayer] = useState(false);
|
||||
const [includeIpLayer, setIncludeIpLayer] = useState(false);
|
||||
const [data, setData] = useState<InterfaceHostProtocolAnalysisResponse | null>(null);
|
||||
const [pathData, setPathData] = useState<InterfaceProtocolPathAnalysisResponse | null>(null);
|
||||
const [conversationData, setConversationData] = useState<ConversationAnalysisResponse | null>(null);
|
||||
const [hostIntelligenceData, setHostIntelligenceData] = useState<HostIntelligenceAnalysisResponse | null>(null);
|
||||
const [selectedHost, setSelectedHost] = useState<HostIntelligenceEvidence | null>(null);
|
||||
const [selectedConversation, setSelectedConversation] = useState<ConversationEvidence | null>(null);
|
||||
const [conversationDetail, setConversationDetail] = useState<ConversationFlowDetailResponse | null>(null);
|
||||
const [conversationDetailLoading, setConversationDetailLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [hostResponse, pathResponse, conversationsResponse, hostIntelResponse] = await Promise.all([
|
||||
fetchInterfaceHostProtocolAnalysis(sinceMinutes, limitPerInterface, limitProtocolsPerHost),
|
||||
fetchInterfaceProtocolPathAnalysis(sinceMinutes, limitPaths),
|
||||
fetchConversationAnalysis(sinceMinutes, limitConversations),
|
||||
fetchHostIntelligenceAnalysis(sinceMinutes, limitHostIntelligence),
|
||||
]);
|
||||
setData(hostResponse);
|
||||
setPathData(pathResponse);
|
||||
setConversationData(conversationsResponse);
|
||||
setHostIntelligenceData(hostIntelResponse);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message ?? 'Failed to load analysis data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
sinceMinutes,
|
||||
limitPerInterface,
|
||||
limitProtocolsPerHost,
|
||||
limitPaths,
|
||||
limitConversations,
|
||||
limitHostIntelligence,
|
||||
]);
|
||||
|
||||
const openConversationDetail = useCallback(
|
||||
async (conversation: ConversationEvidence) => {
|
||||
setSelectedConversation(conversation);
|
||||
setConversationDetail(null);
|
||||
setConversationDetailLoading(true);
|
||||
|
||||
try {
|
||||
const detailResponse = await fetchConversationFlowDetail({
|
||||
flowId: conversation.flow_ids.length === 1 ? conversation.flow_ids[0] : null,
|
||||
srcIpAddress: conversation.src_ip_address,
|
||||
srcMacAddress: conversation.src_mac_address,
|
||||
dstIpAddress: conversation.dst_ip_address,
|
||||
dstMacAddress: conversation.dst_mac_address,
|
||||
srcPort: conversation.src_port,
|
||||
dstPort: conversation.dst_port,
|
||||
protocol: conversation.protocol,
|
||||
sinceMinutes,
|
||||
limitPackets: 1500,
|
||||
});
|
||||
setConversationDetail(detailResponse);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message ?? 'Failed to load conversation detail');
|
||||
} finally {
|
||||
setConversationDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[sinceMinutes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData().catch(() => undefined);
|
||||
}, [loadData]);
|
||||
|
||||
const topologyData = useMemo(
|
||||
() =>
|
||||
buildTopologyData(data?.interfaces ?? [], {
|
||||
includeEthernetLayer,
|
||||
includeIpLayer,
|
||||
}),
|
||||
[data, includeEthernetLayer, includeIpLayer],
|
||||
);
|
||||
const analysisNotes = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set([
|
||||
...(data?.notes ?? []),
|
||||
...(conversationData?.notes ?? []),
|
||||
...(hostIntelligenceData?.notes ?? []),
|
||||
]),
|
||||
),
|
||||
[data, conversationData, hostIntelligenceData],
|
||||
);
|
||||
const columns = useMemo<ColumnsType<ProtocolTableRow>>(
|
||||
() => [
|
||||
{
|
||||
title: 'Interface',
|
||||
dataIndex: 'interface',
|
||||
key: 'interface',
|
||||
width: 140,
|
||||
render: (value: string) => <Tag color="blue">{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'ip_address',
|
||||
key: 'ip_address',
|
||||
render: (value?: string | null) => value ?? '—',
|
||||
},
|
||||
{
|
||||
title: 'MAC',
|
||||
dataIndex: 'mac_address',
|
||||
key: 'mac_address',
|
||||
render: (value?: string | null) => value ?? '—',
|
||||
},
|
||||
{
|
||||
title: 'Protocol',
|
||||
dataIndex: 'protocol',
|
||||
key: 'protocol',
|
||||
width: 140,
|
||||
render: (value: string) => <Tag color={protocolColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{ title: 'Host Packets', dataIndex: 'host_packet_count', key: 'host_packet_count', width: 110 },
|
||||
{ title: 'Protocol Packets', dataIndex: 'protocol_packet_count', key: 'protocol_packet_count', width: 130 },
|
||||
{ title: 'Accept', dataIndex: 'accept_count', key: 'accept_count', width: 90 },
|
||||
{ title: 'Drop', dataIndex: 'drop_count', key: 'drop_count', width: 90 },
|
||||
{ title: 'Reject', dataIndex: 'reject_count', key: 'reject_count', width: 90 },
|
||||
{ title: 'Unknown', dataIndex: 'unknown_count', key: 'unknown_count', width: 90 },
|
||||
{
|
||||
title: 'Last Seen',
|
||||
dataIndex: 'last_seen',
|
||||
key: 'last_seen',
|
||||
width: 220,
|
||||
render: (value: string) => formatTimestamp(value),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const conversationColumns = useMemo<ColumnsType<ConversationEvidence>>(
|
||||
() => [
|
||||
{
|
||||
title: 'Source',
|
||||
key: 'source',
|
||||
render: (_, row) => endpointText(row.src_ip_address, row.src_mac_address),
|
||||
},
|
||||
{
|
||||
title: 'Destination',
|
||||
key: 'destination',
|
||||
render: (_, row) => endpointText(row.dst_ip_address, row.dst_mac_address),
|
||||
},
|
||||
{
|
||||
title: 'Ports',
|
||||
key: 'ports',
|
||||
width: 130,
|
||||
render: (_, row) => `${row.src_port ?? '—'} -> ${row.dst_port ?? '—'}`,
|
||||
},
|
||||
{
|
||||
title: 'Protocol',
|
||||
dataIndex: 'protocol',
|
||||
key: 'protocol',
|
||||
width: 140,
|
||||
render: (value: string) => <Tag color={protocolColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Hostnames',
|
||||
key: 'hostnames',
|
||||
render: (_, row) => renderLabelTags(row.hostnames.slice(0, 4), 'geekblue'),
|
||||
},
|
||||
{ title: 'Packets', dataIndex: 'packet_count', key: 'packet_count', width: 90 },
|
||||
{ title: 'Flows', dataIndex: 'flow_count', key: 'flow_count', width: 80 },
|
||||
{
|
||||
title: 'Bytes',
|
||||
dataIndex: 'byte_count',
|
||||
key: 'byte_count',
|
||||
width: 110,
|
||||
render: (value: number) => formatBytes(value),
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: 'duration_ms',
|
||||
key: 'duration_ms',
|
||||
width: 110,
|
||||
render: (value: number) => formatDurationMs(value),
|
||||
},
|
||||
{
|
||||
title: 'Verdict',
|
||||
key: 'verdict',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Text type="secondary">
|
||||
A {row.accept_count} / D {row.drop_count} / R {row.reject_count}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Last Seen',
|
||||
dataIndex: 'last_seen',
|
||||
key: 'last_seen',
|
||||
width: 220,
|
||||
render: (value: string) => formatTimestamp(value),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const hostColumns = useMemo<ColumnsType<HostIntelligenceEvidence>>(
|
||||
() => [
|
||||
{
|
||||
title: 'Host',
|
||||
key: 'host',
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div>{row.ip_address ?? '—'}</div>
|
||||
<Text type="secondary">{row.mac_address ?? '—'}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Interfaces',
|
||||
key: 'interfaces',
|
||||
render: (_, row) => renderLabelTags(row.interfaces, 'blue'),
|
||||
},
|
||||
{
|
||||
title: 'Hostnames',
|
||||
key: 'hostnames',
|
||||
render: (_, row) => renderLabelTags(row.hostnames.slice(0, 4), 'geekblue'),
|
||||
},
|
||||
{
|
||||
title: 'Top Protocols',
|
||||
key: 'top_protocols',
|
||||
render: (_, row) => renderLabelCountTags(row.top_protocols),
|
||||
},
|
||||
{ title: 'Packets', dataIndex: 'packet_count', key: 'packet_count', width: 90 },
|
||||
{
|
||||
title: 'Bytes',
|
||||
dataIndex: 'byte_count',
|
||||
key: 'byte_count',
|
||||
width: 110,
|
||||
render: (value: number) => formatBytes(value),
|
||||
},
|
||||
{
|
||||
title: 'Role Bias',
|
||||
key: 'role_bias',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<Text type="secondary">
|
||||
src {row.source_count} / dst {row.destination_count}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Last Seen',
|
||||
dataIndex: 'last_seen',
|
||||
key: 'last_seen',
|
||||
width: 220,
|
||||
render: (value: string) => formatTimestamp(value),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||
<Col>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Analysis
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
Explore inferred interface, host, and protocol relationships from captured traffic.
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap size={[12, 12]}>
|
||||
<Space>
|
||||
<Text>Look back</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={60 * 24 * 30}
|
||||
value={sinceMinutes}
|
||||
placeholder="All history"
|
||||
onChange={(value) => setSinceMinutes(value == null ? null : Number(value))}
|
||||
/>
|
||||
<Text type="secondary">minutes, blank = all history</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text>Max hosts per interface</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={1000}
|
||||
value={limitPerInterface}
|
||||
onChange={(value) => setLimitPerInterface(value ?? 50)}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text>Max protocols per host</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100}
|
||||
value={limitProtocolsPerHost}
|
||||
onChange={(value) => setLimitProtocolsPerHost(value ?? 12)}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text>Max packet paths</Text>
|
||||
<InputNumber min={1} max={5000} value={limitPaths} onChange={(value) => setLimitPaths(value ?? 500)} />
|
||||
</Space>
|
||||
<Checkbox checked={includeEthernetLayer} onChange={(event) => setIncludeEthernetLayer(event.target.checked)}>
|
||||
Ethernet layer
|
||||
</Checkbox>
|
||||
<Checkbox checked={includeIpLayer} onChange={(event) => setIncludeIpLayer(event.target.checked)}>
|
||||
IP layer
|
||||
</Checkbox>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => loadData()} loading={loading} type="primary">
|
||||
Refresh
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap size={[12, 12]}>
|
||||
<Space>
|
||||
<Text>Max conversations</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={5000}
|
||||
value={limitConversations}
|
||||
onChange={(value) => setLimitConversations(value ?? 300)}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text>Max host intelligence rows</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={500}
|
||||
value={limitHostIntelligence}
|
||||
onChange={(value) => setLimitHostIntelligence(value ?? 40)}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'overview',
|
||||
label: 'Topology & Protocols',
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
title="Topology Views"
|
||||
extra={data?.since ? <Text type="secondary">Since {formatTimestamp(data.since)}</Text> : null}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'sankey',
|
||||
label: 'Sankey',
|
||||
children: (
|
||||
<div>
|
||||
<Paragraph type="secondary">
|
||||
Shows aggregated interface, host, and protocol relationships across the captured
|
||||
traffic.
|
||||
</Paragraph>
|
||||
<SankeyTopology data={topologyData} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'force',
|
||||
label: 'Force Graph',
|
||||
children: (
|
||||
<div>
|
||||
<Paragraph type="secondary">
|
||||
Useful for exploring clusters and protocol neighborhoods across interfaces and hosts.
|
||||
</Paragraph>
|
||||
<ForceTopology data={topologyData} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'heatmap',
|
||||
label: 'Heatmap',
|
||||
children: (
|
||||
<div>
|
||||
<Paragraph type="secondary">
|
||||
Useful for comparing which hosts are most active in which protocols.
|
||||
</Paragraph>
|
||||
<ProtocolHeatmap data={topologyData} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Protocol Evidence Table">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
This is the underlying aggregated evidence used by the topology views, including verdict counts
|
||||
per interface, host, and protocol.
|
||||
</Paragraph>
|
||||
<Table
|
||||
rowKey="key"
|
||||
columns={columns}
|
||||
dataSource={topologyData.tableRows}
|
||||
size="small"
|
||||
bordered
|
||||
pagination={{ pageSize: 25 }}
|
||||
locale={{
|
||||
emptyText: loading ? 'Loading…' : 'No interface-host-protocol evidence available yet.',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'communication',
|
||||
label: 'Communication',
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card title="Packet Paths">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
Parallel-coordinates view of grouped packet paths as source endpoint to ingress to protocol to
|
||||
egress to destination endpoint.
|
||||
</Paragraph>
|
||||
<PacketPathLanes
|
||||
paths={pathData?.paths ?? []}
|
||||
includeEthernetLayer={includeEthernetLayer}
|
||||
includeIpLayer={includeIpLayer}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Conversation Timeline">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
Time-ordered view of the busiest conversations. Click a bar to inspect the full packet sequence,
|
||||
subflows, and derived request/response events.
|
||||
</Paragraph>
|
||||
<ConversationTimeline
|
||||
conversations={conversationData?.conversations ?? []}
|
||||
selectedKey={selectedConversation ? conversationRowKey(selectedConversation) : null}
|
||||
onSelect={openConversationDetail}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Conversation Matrix">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
Source-to-destination adjacency matrix for the busiest conversations. Cell color and value reflect
|
||||
packet volume, which makes the dominant communication relationships stand out quickly.
|
||||
</Paragraph>
|
||||
<ConversationMatrix conversations={conversationData?.conversations ?? []} />
|
||||
</Card>
|
||||
|
||||
<Card title="Conversation Explorer">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
Directional conversations grouped by source, destination, ports, protocol, and verdict outcome.
|
||||
Click a row for packet-level drill-down.
|
||||
</Paragraph>
|
||||
<Table
|
||||
rowKey={conversationRowKey}
|
||||
columns={conversationColumns}
|
||||
dataSource={conversationData?.conversations ?? []}
|
||||
size="small"
|
||||
bordered
|
||||
onRow={(row) => ({
|
||||
onClick: () => openConversationDetail(row),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={{ pageSize: 20 }}
|
||||
locale={{ emptyText: loading ? 'Loading…' : 'No conversation evidence available yet.' }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'identity',
|
||||
label: 'Identity & Services',
|
||||
children: (
|
||||
<Card title="Host Intelligence">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
Asset-focused view combining interfaces, hostname hints, dominant protocols, likely services, and
|
||||
peer relationships. Click a row to open a focused host detail drawer.
|
||||
</Paragraph>
|
||||
<Table
|
||||
rowKey={(row) => `${row.ip_address ?? 'no-ip'}|${row.mac_address ?? 'no-mac'}`}
|
||||
columns={hostColumns}
|
||||
dataSource={hostIntelligenceData?.hosts ?? []}
|
||||
size="small"
|
||||
bordered
|
||||
onRow={(row) => ({
|
||||
onClick: () => setSelectedHost(row),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={{ pageSize: 15 }}
|
||||
locale={{ emptyText: loading ? 'Loading…' : 'No host intelligence available yet.' }}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Spin>
|
||||
<HostDetailDrawer host={selectedHost} open={selectedHost != null} onClose={() => setSelectedHost(null)} />
|
||||
<ConversationFlowDrawer
|
||||
conversation={selectedConversation}
|
||||
detail={conversationDetail}
|
||||
open={selectedConversation != null}
|
||||
loading={conversationDetailLoading}
|
||||
onClose={() => {
|
||||
setSelectedConversation(null);
|
||||
setConversationDetail(null);
|
||||
setConversationDetailLoading(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,60 @@
|
||||
// src/pages/Firewall.tsx
|
||||
import { Alert, Col, Row } from 'antd';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { fetchRuleset } from '../api/apiClient';
|
||||
import RuleBuilder from '../components/FirewallRuleBuilder';
|
||||
import RulesView from '../components/FirewallRulesetViewer';
|
||||
import { TableOut } from '../types/firewall';
|
||||
|
||||
const EMPTY_TABLES: TableOut[] = [];
|
||||
|
||||
export const Firewall: React.FC = () => {
|
||||
const [tables, setTables] = useState<TableOut[]>(EMPTY_TABLES);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchRuleset();
|
||||
if (!res || res.ruleset == null || typeof res.ruleset === 'string') {
|
||||
setTables(EMPTY_TABLES);
|
||||
} else {
|
||||
setTables(res.ruleset.tables ?? EMPTY_TABLES);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
setTables(EMPTY_TABLES);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// fetch once on mount
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// refresh() can be passed down to children to trigger a re-fetch
|
||||
const refreshRules = useCallback(async () => {
|
||||
await load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="firewall-page">
|
||||
<RulesView />
|
||||
<RuleBuilder />
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
{error && <Alert type="error" message="Could not load ruleset" description={String(error)} showIcon />}
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<RulesView tables={tables} error={error} refreshRules={refreshRules} />
|
||||
</Col>
|
||||
|
||||
<Col xs={24} style={{ marginTop: 16 }}>
|
||||
<RuleBuilder tables={tables} refreshRules={refreshRules} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,162 @@
|
||||
import { useEffect } from 'react';
|
||||
import { fetchFullState } from '../api/apiClient';
|
||||
import { useBackendAPI } from '../hooks/useBackendAPI';
|
||||
import { ApartmentOutlined, AreaChartOutlined, HomeOutlined, MonitorOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Col, List, Row, Space, Typography } from 'antd';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function Home() {
|
||||
const { fullStateQuery } = useBackendAPI();
|
||||
const { data, isLoading, isError, error } = fullStateQuery;
|
||||
import FirewallIcon from '../icons/FirewallIcon';
|
||||
import TerminalIcon from '../icons/TerminalIcon';
|
||||
import { PATHS } from '../routes';
|
||||
|
||||
if (isLoading) return <div>Loading full state…</div>;
|
||||
if (isError) return <div>Error: {(error as Error)?.message}</div>;
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
useEffect(() => {
|
||||
fetchFullState().catch(() => {});
|
||||
}, []);
|
||||
type SectionCard = {
|
||||
key: string;
|
||||
title: string;
|
||||
route?: string;
|
||||
icon: ReactNode;
|
||||
summary: string;
|
||||
bullets: string[];
|
||||
};
|
||||
|
||||
const mainSections: SectionCard[] = [
|
||||
{
|
||||
key: 'home',
|
||||
title: 'Home',
|
||||
route: PATHS.HOME,
|
||||
icon: <HomeOutlined style={{ fontSize: 22 }} />,
|
||||
summary: 'Landing page with a overview of the platform and its main functionalities.',
|
||||
bullets: [''],
|
||||
},
|
||||
{
|
||||
key: 'network',
|
||||
title: 'Network',
|
||||
route: PATHS.NETWORK,
|
||||
icon: <ApartmentOutlined style={{ fontSize: 22 }} />,
|
||||
summary: 'Inspect interfaces, routes, and link-state, create brdiges and enable bridge link state propagation.',
|
||||
bullets: [
|
||||
'Shows the current network state and interface details.',
|
||||
'Creates and removes bridges for traffic interception setups.',
|
||||
'Manages bridge link-state watcher behavior and interface reset operations.',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'firewall',
|
||||
title: 'Firewall',
|
||||
route: PATHS.FIREWALL,
|
||||
icon: <FirewallIcon style={{ fontSize: 22 }} />,
|
||||
summary: 'Build and inspect nftables rules that steer or control packet handling.',
|
||||
bullets: [
|
||||
'Display the current nftables ruleset.',
|
||||
'Create tables, chains and rules without writing everything by hand.',
|
||||
'Push packets into NFQUEUEs.',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sniffing',
|
||||
title: 'Sniffing',
|
||||
route: PATHS.SNIFFING,
|
||||
icon: <MonitorOutlined style={{ fontSize: 22 }} />,
|
||||
summary: 'Start live packet capture sessions and inspect captured traffic across interfaces and bridges.',
|
||||
bullets: [
|
||||
'Start and stop capture sessions per interface or bridge.',
|
||||
'Show capture status to see where packets are currently being collected.',
|
||||
'Displays captured packets for live inspection.',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'scripting',
|
||||
title: 'Scripting',
|
||||
route: PATHS.SCRIPTING,
|
||||
icon: <TerminalIcon style={{ fontSize: 22 }} width={22} height={22} />,
|
||||
summary: 'Manage NFQUEUE Python scripts that can inspect, modify, delay, or drop packets inline.',
|
||||
bullets: [
|
||||
'Upload and download saved scripts and optional requirements.',
|
||||
'Enable and disable scripts as systemd-backed queue workers.',
|
||||
'Example scripts as baseline for developing new use-cases.',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'analysis',
|
||||
title: 'Analysis',
|
||||
route: PATHS.ANALYSIS,
|
||||
icon: <AreaChartOutlined style={{ fontSize: 22 }} />,
|
||||
summary:
|
||||
'Turn captured traffic into higher-level insights such as hosts, paths, conversations, and protocol usage.',
|
||||
bullets: ['Display visualizations from observed traffic.'],
|
||||
},
|
||||
];
|
||||
|
||||
function OverviewCard({ section, onOpen }: { section: SectionCard; onOpen?: (route: string) => void }): ReactElement {
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space align="center">
|
||||
{section.icon}
|
||||
<span>{section.title}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
section.route && onOpen ? (
|
||||
<Button type="link" onClick={() => onOpen(section.route!)}>
|
||||
Open <RightOutlined />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Paragraph style={{ minHeight: 66 }}>{section.summary}</Paragraph>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={section.bullets}
|
||||
renderItem={(item) => (
|
||||
<List.Item style={{ paddingInline: 0 }}>
|
||||
<Text>{item}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Full State</h3>
|
||||
<div>
|
||||
<strong>Interfaces:</strong> {data?.interfaces.length ?? 0}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Routes:</strong> {data?.routes.length ?? 0}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Bridges:</strong> {data?.bridges.length ?? 0}
|
||||
</div>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(data, null, 2)}</pre>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<Title level={2} style={{ marginTop: 0 }}>
|
||||
MITM Webserver App Overview
|
||||
</Title>
|
||||
<Paragraph>
|
||||
This application is a proof-of-concept platform for network traffic configuration, interception,
|
||||
manipulation, and analyzation. It combines network setup, firewall control, packet capture, inline NFQUEUE
|
||||
scripting, and higher-level traffic analysis in one app while trying to stay hidden from the communicating
|
||||
entities.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<Title level={4} style={{ marginTop: 0 }}>
|
||||
Main Pages
|
||||
</Title>
|
||||
<Paragraph>
|
||||
These are the core working areas of the application. Each page supports a different phase of network
|
||||
experimentation, monitoring, or analysis.
|
||||
</Paragraph>
|
||||
<Row gutter={[16, 16]}>
|
||||
{mainSections.map((section) => (
|
||||
<Col xs={24} md={12} xl={8} key={section.key}>
|
||||
<OverviewCard section={section} onOpen={(route) => navigate(route)} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,228 @@
|
||||
// src/pages/BridgesManager.tsx
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Button, Col, Form, Input, message, Modal, Popconfirm, Row, Select, Space, Table, Tag, Typography } from 'antd';
|
||||
import { DeleteOutlined, RedoOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
notification,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useBackendAPI } from '../hooks/useBackendAPI';
|
||||
import type { BridgeInfo, InterfaceInfo } from '../types/network';
|
||||
|
||||
import {
|
||||
createBridge,
|
||||
disableBridgeLinkStateWatcher,
|
||||
enableBridgeLinkStateWatcher,
|
||||
fetchFullState,
|
||||
getNetworkStateWebSocketUrl,
|
||||
removeBridge,
|
||||
resetInterfaceDefaults,
|
||||
} from '../api/apiClient';
|
||||
import type {
|
||||
EthernetProfile,
|
||||
BridgeInfo,
|
||||
BridgeLinkStateWatcherStatus,
|
||||
FullState,
|
||||
InterfaceInfo,
|
||||
InterfaceResetDefaultsResponse,
|
||||
} from '../types/network';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function Network() {
|
||||
const {
|
||||
interfacesQuery,
|
||||
bridgesQuery,
|
||||
createBridge,
|
||||
removeBridge,
|
||||
fetchBridges,
|
||||
fetchInterfaces,
|
||||
refreshBridges,
|
||||
refreshInterfaces,
|
||||
isCreating,
|
||||
isRemoving,
|
||||
} = useBackendAPI();
|
||||
|
||||
const [bridgeModalVisible, setBridgeModalVisible] = useState(false);
|
||||
const [watcherModalVisible, setWatcherModalVisible] = useState(false);
|
||||
const [bridgeForm] = Form.useForm();
|
||||
const [watcherForm] = Form.useForm();
|
||||
const [networkState, setNetworkState] = useState<FullState>();
|
||||
const [watcherStatuses, setWatcherStatuses] = useState<Record<string, BridgeLinkStateWatcherStatus>>({});
|
||||
const [watcherBridgeName, setWatcherBridgeName] = useState<string>();
|
||||
const [watcherBusyBridge, setWatcherBusyBridge] = useState<string>();
|
||||
const [resetBusyInterface, setResetBusyInterface] = useState<string>();
|
||||
|
||||
const getNetworkPageState = (silent = false) => {
|
||||
fetchFullState()
|
||||
.then((state) => {
|
||||
setNetworkState(state);
|
||||
setWatcherStatuses(indexWatchers(state.watchers ?? []));
|
||||
if (!silent) {
|
||||
notification.success({
|
||||
message: 'Success',
|
||||
description: 'Network state updated.',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch network state:', error);
|
||||
notification.error({
|
||||
message: 'Error',
|
||||
description: 'Failed to fetch network state.',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function indexWatchers(watchers: BridgeLinkStateWatcherStatus[]) {
|
||||
return watchers.reduce<Record<string, BridgeLinkStateWatcherStatus>>((acc, watcher) => {
|
||||
acc[watcher.bridge] = watcher;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function applySnapshot(state: FullState) {
|
||||
setNetworkState(state);
|
||||
setWatcherStatuses(indexWatchers(state.watchers ?? []));
|
||||
}
|
||||
|
||||
// fetch on mount (explicit, since queries are disabled by default)
|
||||
useEffect(() => {
|
||||
fetchInterfaces().catch(() => {});
|
||||
fetchBridges().catch(() => {});
|
||||
getNetworkPageState(true);
|
||||
}, []);
|
||||
|
||||
// Table data
|
||||
const interfaces = interfacesQuery.data ?? [];
|
||||
const bridges = bridgesQuery.data ?? [];
|
||||
useEffect(() => {
|
||||
let reconnectTimer: number | undefined;
|
||||
let socket: WebSocket | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const connect = () => {
|
||||
socket = new WebSocket(getNetworkStateWebSocketUrl());
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload?.type === 'network_state' && payload.snapshot) {
|
||||
applySnapshot(payload.snapshot as FullState);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse network websocket payload:', error);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
reconnectTimer = window.setTimeout(connect, 2000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
}
|
||||
socket?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function renderEthernetProfile(profile?: EthernetProfile | null) {
|
||||
if (!profile) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const speed = profile.speed_mbps ? `${profile.speed_mbps} Mb/s` : 'speed ?';
|
||||
const duplex = profile.duplex ?? 'duplex ?';
|
||||
const autoneg = typeof profile.autoneg === 'boolean' ? (profile.autoneg ? 'autoneg on' : 'autoneg off') : 'autoneg ?';
|
||||
return `${speed}, ${duplex}, ${autoneg}`;
|
||||
}
|
||||
|
||||
// build select options from interfaces list
|
||||
const interfaceOptions = useMemo(
|
||||
() =>
|
||||
interfaces.map((it: InterfaceInfo) => ({
|
||||
label: it.name,
|
||||
value: it.name,
|
||||
})),
|
||||
[interfaces],
|
||||
networkState?.interfaces.map((iface: InterfaceInfo) => ({
|
||||
label: iface.name,
|
||||
value: iface.name,
|
||||
})) ?? [],
|
||||
[networkState],
|
||||
);
|
||||
|
||||
const watcherData = useMemo(
|
||||
() =>
|
||||
(networkState?.bridges ?? []).map((bridge) => ({
|
||||
bridge,
|
||||
watcher: watcherStatuses[bridge.ifname],
|
||||
})),
|
||||
[networkState, watcherStatuses],
|
||||
);
|
||||
|
||||
const watcherMemberColumns = useMemo(
|
||||
() => [
|
||||
{ title: 'Member', dataIndex: 'ifname', key: 'ifname' },
|
||||
{
|
||||
title: 'Admin',
|
||||
dataIndex: 'admin_up',
|
||||
key: 'admin_up',
|
||||
width: 90,
|
||||
render: (value: boolean | null | undefined) => <Tag color={value ? 'green' : 'default'}>{value ? 'up' : 'down'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Carrier',
|
||||
dataIndex: 'carrier_up',
|
||||
key: 'carrier_up',
|
||||
width: 100,
|
||||
render: (value: boolean | null | undefined) => <Tag color={value ? 'green' : 'default'}>{value ? 'up' : 'down'}</Tag>,
|
||||
},
|
||||
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90, render: (value: number | null | undefined) => value ?? '—' },
|
||||
{
|
||||
title: 'Link',
|
||||
dataIndex: 'ethernet_profile',
|
||||
key: 'ethernet_profile',
|
||||
render: (value: EthernetProfile | null | undefined) => renderEthernetProfile(value),
|
||||
},
|
||||
{
|
||||
title: 'Operstate',
|
||||
dataIndex: 'operstate',
|
||||
key: 'operstate',
|
||||
render: (value: string | null | undefined) => <Tag>{value ?? 'unknown'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Role',
|
||||
key: 'role',
|
||||
width: 120,
|
||||
render: (_: unknown, record: { link_ready: boolean; suppressed: boolean }) => {
|
||||
if (record.suppressed) {
|
||||
return <Tag color="orange">suppressed</Tag>;
|
||||
}
|
||||
return <Tag color={record.link_ready ? 'green' : 'red'}>{record.link_ready ? 'ready' : 'failing'}</Tag>;
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// Columns for interfaces table (read-only)
|
||||
const interfaceColumns: ColumnsType<InterfaceInfo> = useMemo(
|
||||
() => [
|
||||
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => <Tag>{s}</Tag> },
|
||||
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (m) => m ?? '—' },
|
||||
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state}</Tag> },
|
||||
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (mac) => mac ?? '—' },
|
||||
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90 },
|
||||
{
|
||||
title: 'Link',
|
||||
dataIndex: 'ethernet_profile',
|
||||
key: 'ethernet_profile',
|
||||
render: (value: EthernetProfile | null | undefined) => renderEthernetProfile(value),
|
||||
},
|
||||
{
|
||||
title: 'Addresses',
|
||||
dataIndex: 'addresses',
|
||||
key: 'addresses',
|
||||
render: (addrs: any[]) =>
|
||||
addrs?.length ? (
|
||||
<Space orientation="vertical">
|
||||
{addrs.map((a) => (
|
||||
<span key={`${a.address}/${a.prefixlen}`}>
|
||||
{a.address}/{a.prefixlen} ({a.family})
|
||||
render: (addresses: any[]) =>
|
||||
addresses?.length ? (
|
||||
<Space direction="vertical">
|
||||
{addresses.map((address) => (
|
||||
<span key={`${address.address}/${address.prefixlen}`}>
|
||||
{address.address}/{address.prefixlen} ({address.family})
|
||||
</span>
|
||||
))}
|
||||
</Space>
|
||||
@@ -70,64 +230,214 @@ export default function Network() {
|
||||
<span>—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (_: unknown, record: InterfaceInfo) => (
|
||||
<Popconfirm
|
||||
title={`Reset ${record.name} to defaults?`}
|
||||
description="Sets MTU to 1500, enables autoneg, and brings the interface up."
|
||||
onConfirm={() => handleResetInterface(record.name)}
|
||||
okText="Reset"
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<Button
|
||||
icon={<RedoOutlined />}
|
||||
loading={resetBusyInterface === record.name}
|
||||
aria-label={`Reset ${record.name} to defaults`}
|
||||
title={`Reset ${record.name} to defaults`}
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[resetBusyInterface],
|
||||
);
|
||||
|
||||
// Columns for bridges table (with remove action)
|
||||
const bridgeColumns: ColumnsType<BridgeInfo> = useMemo(
|
||||
() => [
|
||||
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
|
||||
{ title: 'Name', dataIndex: 'ifname', key: 'ifname' },
|
||||
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => <Tag>{s ?? '—'}</Tag> },
|
||||
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state ?? '—'}</Tag> },
|
||||
{
|
||||
title: 'Members',
|
||||
dataIndex: 'members',
|
||||
key: 'members',
|
||||
render: (members: any[]) => (members?.length ? members.map((m) => m.ifname).join(', ') : '—'),
|
||||
render: (members: any[]) => (members?.length ? members.map((member) => member.ifname).join(', ') : '—'),
|
||||
},
|
||||
{
|
||||
title: 'Link Watcher',
|
||||
key: 'watcher',
|
||||
width: 160,
|
||||
render: (_, record: BridgeInfo) => {
|
||||
const watcher = watcherStatuses[record.ifname];
|
||||
if (!watcher?.active) {
|
||||
return <Tag>disabled</Tag>;
|
||||
}
|
||||
if (watcher.last_error) {
|
||||
return <Tag color="red">error</Tag>;
|
||||
}
|
||||
if (watcher.suppressed_members.length > 0) {
|
||||
return <Tag color="orange">propagating</Tag>;
|
||||
}
|
||||
return <Tag color="green">active</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
render: (_: any, record: BridgeInfo) => (
|
||||
width: 260,
|
||||
render: (_, record: BridgeInfo) => (
|
||||
<Space>
|
||||
{watcherStatuses[record.ifname]?.active ? (
|
||||
<Button
|
||||
onClick={() => handleDisableWatcher(record.ifname)}
|
||||
loading={watcherBusyBridge === record.ifname}
|
||||
>
|
||||
Disable watcher
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => openEnableWatcherModal(record.ifname)}
|
||||
loading={watcherBusyBridge === record.ifname}
|
||||
>
|
||||
Enable watcher
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={`Remove bridge ${record.ifname}?`}
|
||||
onConfirm={() => handleRemoveBridge(record.ifname)}
|
||||
okText="Remove"
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} loading={isRemoving} size="small">
|
||||
Remove
|
||||
</Button>
|
||||
<Button danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isRemoving],
|
||||
[watcherBusyBridge, watcherStatuses],
|
||||
);
|
||||
async function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
|
||||
const ifaceList = values.interfaces ?? [];
|
||||
createBridge({ name: values.name, interfaces: ifaceList })
|
||||
|
||||
function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
|
||||
const interfaces = values.interfaces ?? [];
|
||||
createBridge({ name: values.name, interfaces })
|
||||
.then(() => {
|
||||
message.success(`Bridge ${values.name} created`);
|
||||
notification.success({
|
||||
message: 'Success',
|
||||
description: `Bridge ${values.name} created`,
|
||||
});
|
||||
setBridgeModalVisible(false);
|
||||
getNetworkPageState(true);
|
||||
bridgeForm.resetFields();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
message.error((err as Error).message ?? 'Failed to create bridge');
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
notification.error({
|
||||
message: 'Error',
|
||||
description: (error as Error).message ?? 'Failed to create bridge',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveBridge(name: string) {
|
||||
removeBridge({ name })
|
||||
.then(() => {
|
||||
message.success(`Bridge ${name} removed`);
|
||||
notification.success({
|
||||
message: 'Success',
|
||||
description: `Bridge ${name} removed`,
|
||||
});
|
||||
getNetworkPageState(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
message.error((err as Error).message ?? 'Failed to remove bridge');
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
notification.error({
|
||||
message: 'Error',
|
||||
description: (error as Error).message ?? 'Failed to remove bridge',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openEnableWatcherModal(bridgeName: string) {
|
||||
setWatcherBridgeName(bridgeName);
|
||||
watcherForm.setFieldsValue({ recovery_holdoff_seconds: 1.0 });
|
||||
setWatcherModalVisible(true);
|
||||
}
|
||||
|
||||
function handleEnableWatcher(values: { recovery_holdoff_seconds: number }) {
|
||||
if (!watcherBridgeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWatcherBusyBridge(watcherBridgeName);
|
||||
enableBridgeLinkStateWatcher(watcherBridgeName, values)
|
||||
.then(() => {
|
||||
notification.success({
|
||||
message: 'Success',
|
||||
description: `Link-state watcher enabled for ${watcherBridgeName}`,
|
||||
});
|
||||
setWatcherModalVisible(false);
|
||||
setWatcherBridgeName(undefined);
|
||||
watcherForm.resetFields();
|
||||
getNetworkPageState(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
notification.error({
|
||||
message: 'Error',
|
||||
description: (error as Error).message ?? 'Failed to enable link-state watcher',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setWatcherBusyBridge(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDisableWatcher(bridgeName: string) {
|
||||
setWatcherBusyBridge(bridgeName);
|
||||
disableBridgeLinkStateWatcher(bridgeName)
|
||||
.then(() => {
|
||||
notification.success({
|
||||
message: 'Success',
|
||||
description: `Link-state watcher disabled for ${bridgeName}`,
|
||||
});
|
||||
getNetworkPageState(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
notification.error({
|
||||
message: 'Error',
|
||||
description: (error as Error).message ?? 'Failed to disable link-state watcher',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setWatcherBusyBridge(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function handleResetInterface(ifname: string) {
|
||||
setResetBusyInterface(ifname);
|
||||
resetInterfaceDefaults({ interfaces: [ifname] })
|
||||
.then((response: InterfaceResetDefaultsResponse) => {
|
||||
const result = response.results[0];
|
||||
notification.success({
|
||||
message: 'Success',
|
||||
description: result?.message
|
||||
? `${ifname}: ${result.message}`
|
||||
: `${ifname} reset to defaults`,
|
||||
});
|
||||
getNetworkPageState(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
notification.error({
|
||||
message: 'Error',
|
||||
description: (error as Error).message ?? `Failed to reset ${ifname}`,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setResetBusyInterface(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,67 +449,127 @@ export default function Network() {
|
||||
<Paragraph type="secondary">View system interfaces and manage network bridges.</Paragraph>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
// ensure up-to-date interface list when opening modal
|
||||
refreshInterfaces();
|
||||
setBridgeModalVisible(true);
|
||||
}}
|
||||
>
|
||||
Create bridge
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
refreshBridges();
|
||||
refreshInterfaces();
|
||||
message.success('Refreshing…');
|
||||
}}
|
||||
>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => getNetworkPageState()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<div style={{ background: '#fff', padding: 12, borderRadius: 6, minHeight: 220 }}>
|
||||
<Title level={5} style={{ marginBottom: 12 }}>
|
||||
Interfaces ({interfaces.length})
|
||||
</Title>
|
||||
<Card title={`Interfaces (${networkState?.interfaces.length ?? 0})`} style={{ overflow: 'auto' }}>
|
||||
<Table
|
||||
rowKey={(r: InterfaceInfo) => r.ifindex}
|
||||
dataSource={interfaces}
|
||||
rowKey={(row: InterfaceInfo) => row.ifindex}
|
||||
dataSource={networkState?.interfaces ?? []}
|
||||
columns={interfaceColumns}
|
||||
pagination={{ pageSize: 8 }}
|
||||
size="small"
|
||||
loading={interfacesQuery.isFetching || interfacesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={10}>
|
||||
<div style={{ background: '#fff', padding: 12, borderRadius: 6, minHeight: 220 }}>
|
||||
<Title level={5} style={{ marginBottom: 12 }}>
|
||||
Bridges ({bridges.length})
|
||||
</Title>
|
||||
<Card
|
||||
title={`Bridges (${networkState?.bridges.length ?? 0})`}
|
||||
style={{ overflow: 'auto' }}
|
||||
extra={
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
getNetworkPageState(true);
|
||||
setBridgeModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey={(r: BridgeInfo) => String(r.ifindex)}
|
||||
dataSource={bridges}
|
||||
rowKey={(row: BridgeInfo) => String(row.ifindex)}
|
||||
dataSource={networkState?.bridges ?? []}
|
||||
columns={bridgeColumns}
|
||||
pagination={{ pageSize: 6 }}
|
||||
size="small"
|
||||
loading={bridgesQuery.isFetching || bridgesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ marginTop: 16 }}>
|
||||
<Col span={24}>
|
||||
<Card title="Bridge Link-State Propagation">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
Enable a watcher on a bridge to force the sibling bridge ports down when one member loses link.
|
||||
</Paragraph>
|
||||
{(networkState?.bridges.length ?? 0) === 0 ? (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
No bridges available.
|
||||
</Paragraph>
|
||||
) : (
|
||||
watcherData.map(({ bridge, watcher }) => {
|
||||
const members = Object.values(watcher?.members ?? {});
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={bridge.ifname}
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<span>{bridge.ifname}</span>
|
||||
<Tag color={watcher?.active ? 'green' : 'default'}>
|
||||
{watcher?.active ? 'watching' : 'disabled'}
|
||||
</Tag>
|
||||
{watcher?.last_error ? <Tag color="red">error</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
watcher?.active ? (
|
||||
<Button onClick={() => handleDisableWatcher(bridge.ifname)} loading={watcherBusyBridge === bridge.ifname}>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => openEnableWatcherModal(bridge.ifname)} loading={watcherBusyBridge === bridge.ifname}>
|
||||
Enable
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Descriptions size="small" column={4} style={{ marginBottom: members.length ? 12 : 0 }}>
|
||||
<Descriptions.Item label="Members">{bridge.members.map((member) => member.ifname).join(', ') || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Mode">{watcher?.event_driven ? 'netlink events' : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Failure holdoff">
|
||||
{watcher?.failure_holdoff_seconds ? `${watcher.failure_holdoff_seconds}s` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Recovery holdoff">
|
||||
{watcher?.recovery_holdoff_seconds ? `${watcher.recovery_holdoff_seconds}s` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Suppressed">
|
||||
{watcher?.suppressed_members.length ? watcher.suppressed_members.join(', ') : 'none'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last action">{watcher?.last_action ?? watcher?.message ?? '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{members.length ? (
|
||||
<Table
|
||||
rowKey={(row) => row.ifname}
|
||||
dataSource={members}
|
||||
columns={watcherMemberColumns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{watcher?.active ? 'Waiting for watcher state.' : 'Watcher is disabled.'}
|
||||
</Paragraph>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Create Bridge Modal */}
|
||||
<Modal
|
||||
title="Create Bridge"
|
||||
open={bridgeModalVisible}
|
||||
@@ -207,7 +577,6 @@ export default function Network() {
|
||||
setBridgeModalVisible(false);
|
||||
bridgeForm.resetFields();
|
||||
}}
|
||||
okButtonProps={{ loading: isCreating }}
|
||||
onOk={() => bridgeForm.submit()}
|
||||
>
|
||||
<Form form={bridgeForm} layout="vertical" onFinish={handleCreateBridge}>
|
||||
@@ -215,20 +584,47 @@ export default function Network() {
|
||||
<Input placeholder="e.g. br0" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Select field populated from interfaces endpoint */}
|
||||
<Form.Item name="interfaces" label="Interfaces (select one or more)">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={interfaces.length ? 'Select interfaces...' : 'No interfaces available'}
|
||||
placeholder={networkState?.interfaces.length ? 'Select interfaces...' : 'No interfaces available'}
|
||||
options={interfaceOptions}
|
||||
showSearch
|
||||
allowClear
|
||||
loading={interfacesQuery.isFetching || interfacesQuery.isLoading}
|
||||
disabled={interfacesQuery.isFetching || interfacesQuery.isLoading || interfaceOptions.length === 0}
|
||||
disabled={interfaceOptions.length === 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={watcherBridgeName ? `Enable Link-State Watcher for ${watcherBridgeName}` : 'Enable Link-State Watcher'}
|
||||
open={watcherModalVisible}
|
||||
onCancel={() => {
|
||||
setWatcherModalVisible(false);
|
||||
setWatcherBridgeName(undefined);
|
||||
watcherForm.resetFields();
|
||||
}}
|
||||
onOk={() => watcherForm.submit()}
|
||||
>
|
||||
<Form
|
||||
form={watcherForm}
|
||||
layout="vertical"
|
||||
onFinish={handleEnableWatcher}
|
||||
initialValues={{ recovery_holdoff_seconds: 1.0 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="recovery_holdoff_seconds"
|
||||
label="Recovery holdoff (seconds)"
|
||||
rules={[{ required: true, message: 'Please provide a recovery holdoff' }]}
|
||||
>
|
||||
<InputNumber min={0.05} max={10} step={0.05} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
The backend reacts to kernel link events and waits for this holdoff before restoring sibling ports after recovery.
|
||||
</Paragraph>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||