Compare commits

..

253 Commits

Author SHA1 Message Date
Marcus Almert
9106cac2a2 documentation md files
Some checks failed
Build and Deploy MITM Webserver / build (push) Has been cancelled
Build and Deploy MITM Webserver / traffic_target (push) Has been cancelled
2026-08-30 17:31:46 +02:00
68827ed7e3 nftables section
Some checks failed
Build and Deploy MITM Webserver / build (push) Has been cancelled
Build and Deploy MITM Webserver / traffic_target (push) Has been cancelled
2026-08-08 17:12:07 +02:00
a4b19f8c3e doc
Some checks failed
Build and Deploy MITM Webserver / build (push) Has been cancelled
Build and Deploy MITM Webserver / traffic_target (push) Has been cancelled
2026-05-23 12:19:22 +02:00
6e7a1ccb1b add measures
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-05-08 23:42:14 +02:00
c9c1d346fd fix flent
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-05-08 21:55:25 +02:00
a900fb8f8b path fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-05-08 21:36:36 +02:00
f24a230f9d flent
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-05-08 21:34:23 +02:00
8929878f13 progress
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-05-08 21:15:22 +02:00
a9ff3a2987 network benchmark
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 2s
Build and Deploy MITM Webserver / build (push) Successful in 13s
2026-05-08 20:51:03 +02:00
10f0e518c7 benchmark mode added test
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-05-03 20:54:37 +02:00
c40ddf4ded overload batching improvements
Some checks failed
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Failing after 23m3s
2026-05-03 20:11:42 +02:00
bf63c7c1a9 try overload fix timestamp null error
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-05-03 19:52:49 +02:00
1614d22257 try overload dirty timout
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-05-03 19:45:20 +02:00
b4e4e27c4b try overload fix 2, backend upsert and test script fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-05-03 19:13:24 +02:00
3eb39a71ad try fix overload
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-05-03 18:42:26 +02:00
945b259ebb scripts and texts
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-05-03 16:38:37 +02:00
b5c6409f85 add new example scripts
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-04-18 11:45:15 +02:00
9c982e361c egal
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-17 23:15:20 +02:00
76256d56f2 test packet rewrite dns example
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-17 22:11:15 +02:00
3ca1d52972 scripting improv
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-04-17 22:02:50 +02:00
ede4b3e78d script protection update
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 1m41s
2026-04-17 21:44:28 +02:00
ead2d5f217 remove disable script protection 2026-04-17 21:40:36 +02:00
d90c5e1650 add example icmp drop script
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-04-17 21:36:00 +02:00
794a727dcb test script examples
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-17 21:32:39 +02:00
c9735faf46 test new file guard and auto deploy
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 12s
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
2026-04-17 19:48:22 +02:00
b974b45bb2 try fix uid unique error
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-12 20:19:52 +02:00
89f1f00b16 fix strict egress detetermination
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-04-12 20:12:11 +02:00
15952ef125 fix path af_packet interface
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-12 20:09:04 +02:00
bef04dbe31 test new egress ingress detection strategy
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-12 19:10:36 +02:00
a578b08041 interface dirextion fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-04-12 19:02:38 +02:00
f554ddb235 add verdict, interface top level storing
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-12 18:58:03 +02:00
af17029388 tshark fix backfill af packet bridge
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-12 18:42:29 +02:00
9c6eaaa7b2 try new correlation af packet bridge mode
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-12 18:27:41 +02:00
4521b9d99e test af_packet mode on bridges
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-04-12 17:58:19 +02:00
9ee47284e0 AF_PACKET terminology cleanup
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-04-12 17:41:22 +02:00
cb4c6ff564 OSI chapter
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 7s
Build and Deploy MITM Webserver / build (push) Successful in 14s
2026-04-12 17:25:45 +02:00
0afe49053c first thesis start
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-04-02 22:40:36 +02:00
8153dc283c improve flow indication in table
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 13s
2026-04-02 21:42:07 +02:00
74b961e152 add visualizations, better scaling
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-04-01 23:10:59 +02:00
8f6d795e87 fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-04-01 18:32:48 +02:00
ecb7d7f258 test new flow stream analysis
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-04-01 18:29:18 +02:00
ae432b7437 test visuals
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-31 22:37:00 +02:00
72bab93aa3 test with new analysis layout
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-03-31 22:15:01 +02:00
cedc52eb8d test path visu
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-30 23:21:36 +02:00
5f6eedf859 add more sankey options
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-30 23:07:14 +02:00
1a0d220f11 add sankey ip ethernet layer
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-30 23:04:05 +02:00
84fa1d3628 fix sankey counting duplication overwrite
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-30 23:00:37 +02:00
cb73cbac09 fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-30 22:44:55 +02:00
da6436f423 stream flow proto fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-30 22:38:03 +02:00
a0efa8ba27 fix hopefully
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-30 22:14:48 +02:00
de6edd6b68 app proto flow determination improvement
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-30 22:06:41 +02:00
dbd052a7ac db test
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-30 21:49:00 +02:00
d9cb501879 fix db query
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-30 21:46:13 +02:00
95c0302b6b fix column name errror
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-30 21:42:35 +02:00
945767f0ce fix visu error api
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-30 21:39:50 +02:00
0463075782 test visualization with d3js
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-30 21:34:40 +02:00
f758901f83 add analysis base api
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-03-30 20:21:52 +02:00
ce35be1e1e fix empty null value db insert
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-10 22:00:39 +01:00
e8bed95162 db deadlock fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-10 21:49:54 +01:00
f0f3f9861a add session id to db
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-10 21:45:30 +01:00
87c4ef04e9 fix db insert
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-10 21:40:06 +01:00
c2fb35155c try to deduplicate db rows
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-10 21:33:41 +01:00
32aa17e0cf flow id prefix fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-10 21:22:15 +01:00
c370374a8a flow id sessions prefix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-10 21:16:32 +01:00
57b0ac5b25 +x
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 21:54:34 +01:00
fefbefc5e1 fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 21:53:53 +01:00
0e4dfd6859 test
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-03-09 21:49:05 +01:00
1489c3535f test
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-09 21:27:36 +01:00
6fa0ab0ee1 better transient state handling
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 18:05:52 +01:00
234827f5bc fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 18:01:35 +01:00
a6825dd790 none fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 17:58:06 +01:00
c592671e6f try fix endless loop again
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 17:53:49 +01:00
0e8f08251e fix loop endless
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 17:48:39 +01:00
ce53ef5101 link fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 17:37:51 +01:00
b836bf3f02 reset button interfaces
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 17:23:00 +01:00
650b92d2c5 autoneg fix 2
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 17:18:28 +01:00
f303b6de6a autoneg bug
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 17:15:40 +01:00
ab4112e8b9 test config macthing
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 16:58:53 +01:00
76dda73827 fix
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 16:50:19 +01:00
40d8b15417 websockets and network interface datat added
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 16:45:01 +01:00
e919853d07 better config sync without suppressing
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 16:35:26 +01:00
8efe797220 fix debounce link config changes
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 16:28:25 +01:00
3f0e3c5400 include more link layer mirrors
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 16:13:53 +01:00
6534f2c4fa test bridge state recovery
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 16:02:47 +01:00
665c0b4475 link watcher switch to event based instead of polling
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-09 15:49:13 +01:00
c57d6ecc67 test re up wathcers state
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 15:36:34 +01:00
4c491cf4b1 test nic state link api
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 15:29:53 +01:00
d2179b2813 test fix timestampt to capture time not db insert time
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 14:33:43 +01:00
a49ee5b9bd flow showing
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-09 12:54:57 +01:00
a111d5ea00 fix permissions
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
2026-03-08 17:50:21 +01:00
a388f253ef runner toekn
Some checks failed
Build and Deploy MITM Webserver / build (push) Successful in 10s
Build and Deploy MITM Webserver / traffic_target (push) Has been cancelled
2026-03-08 17:47:44 +01:00
dc76daf217 test runner traffic target
Some checks failed
Build and Deploy MITM Webserver / build (push) Successful in 10s
Build and Deploy MITM Webserver / traffic_target (push) Has been cancelled
2026-03-08 17:40:07 +01:00
5ea4dd388e tshark protocol restriction lifted, frontedn details for packet
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-08 16:54:28 +01:00
c41e68c0f3 tshark full usage
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 20:36:57 +01:00
8de4c880b0 tshark test 6
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 20:20:25 +01:00
e81def5295 tshark fix 4
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 20:12:33 +01:00
eb2f2feba8 tshark test 3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 20:07:55 +01:00
09b4add62b test tshark fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 19:59:16 +01:00
22bb6b8526 Refactor: Remove NFStream and flow identity utilities; introduce Tshark manager
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
- Deleted flow_identity.py and nfstream_flow_worker.py as they are no longer needed.
- Removed nfstream_manager.py and its associated logic for managing NFStream workers.
- Added tshark_manager.py to manage tshark packet enrichment and matching.
- Updated setup_build_server.sh to include default environment variables for tshark.
- Implemented packet signature generation and enrichment logic in the new TsharkManager class.
2026-03-07 19:51:29 +01:00
dfad09fa21 nfstream debug
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 12s
2026-03-07 18:32:21 +01:00
fec6ec29c7 type nfstream
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 16s
2026-03-07 18:26:30 +01:00
9fc2079e86 test better shutdown nfstream
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 18:23:28 +01:00
01f9f6b5bd test nfstream fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m49s
2026-03-07 18:19:33 +01:00
3f0331762e remove ndpi
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m49s
2026-03-07 18:14:16 +01:00
3ea07bf1df try nfstream
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 19s
2026-03-07 18:09:38 +01:00
02f76144fa fix length mismatch
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 17:10:19 +01:00
1fdf6ae3b9 traffic fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 17:06:04 +01:00
13cc2612a2 stop sniffer
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 17:04:17 +01:00
cef433f9d7 tc test 6
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 16:50:42 +01:00
29986d5073 tc test 5
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 16:48:37 +01:00
ac849ba3fe tc test 4
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 16:45:32 +01:00
e3c683aea6 test 3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 16:44:00 +01:00
f53bfe2d64 test again
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 16:40:27 +01:00
191e3e0da4 fix tc test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 16:38:38 +01:00
0c69daf229 tc test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 16:36:24 +01:00
f55e899fc5 ebpf parsing minimized
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 16:10:52 +01:00
62ac1d4300 suppress ipv6 noise between veth interfaces
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 16:01:17 +01:00
ec411610c8 fix tc race
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 15:49:57 +01:00
e73726fd1b ebpf fix 2
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 15:44:48 +01:00
6ad2dd2435 ebpf fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 15:43:07 +01:00
5aac5974f5 error bridge veth
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 15:41:08 +01:00
264da68f11 manag veth directly with sniffer
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 15:38:56 +01:00
a282b89d45 tc full packet test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 15:13:08 +01:00
2bdbe230d1 debug
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 11:11:57 +01:00
e099f840d2 kprobe log fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 11:02:45 +01:00
dc3f7c0abd fix database insert and validation
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-07 10:55:46 +01:00
c19fab4d32 add config
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-07 10:44:45 +01:00
8e6c759cb8 try to fix websocket shutdown
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m39s
2026-03-07 10:35:29 +01:00
299f2e9978 fix get ep verdict
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-03-07 10:32:23 +01:00
161ed0f145 fix eth type and db
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-03-07 10:26:51 +01:00
79b2f1e673 fix egress
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-07 10:15:32 +01:00
f9bbe9b73c test ebpf
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-03-07 10:06:24 +01:00
a98054cb5b try dev branch
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-06 22:51:16 +01:00
3d28657229 again setup
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-06 22:46:00 +01:00
c18f56000d try setup again
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-06 22:42:07 +01:00
f96315918e try again setup codex
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-06 22:37:10 +01:00
47127234a4 test fix import ndpi
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-03-06 22:31:51 +01:00
e3589fd7c3 fix codex metdatada ndpi build
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-03-06 22:22:41 +01:00
25bb7be29d test backend codec ndpi
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-03-06 22:05:45 +01:00
991f26e5f2 fix traffic gen keypress interrupt
Some checks failed
Build and Deploy MITM Webserver / build (push) Failing after 2s
2026-03-06 20:31:32 +01:00
2aca2760df add traffic generator scripts
Some checks failed
Build and Deploy MITM Webserver / build (push) Failing after 3s
2026-03-06 20:21:21 +01:00
b60a1d3118 file structure and comments unified
Some checks failed
Build and Deploy MITM Webserver / build (push) Failing after 3s
2026-03-06 19:03:26 +01:00
7b9a7d3a4b test fix 3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-05 16:05:26 +01:00
e532573db9 test fix backend types
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m3s
2026-03-05 15:59:31 +01:00
d06c8adcdd test backend typing packets
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-05 15:46:41 +01:00
7812f8715f cleanup frontend notifications
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m39s
2026-03-01 20:47:29 +01:00
0f2a342b75 test database privilige and index + delete ep packets
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m4s
2026-03-01 18:23:08 +01:00
613c8b8c5a test clear package api
Some checks failed
Build and Deploy MITM Webserver / build (push) Has been cancelled
2026-03-01 17:57:24 +01:00
bd37cdf628 test websocket data enrichment
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
2026-03-01 17:44:09 +01:00
a1dbbcb8d5 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-01 17:32:25 +01:00
6689f01974 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m38s
2026-03-01 17:29:39 +01:00
f8647ab3ed frontedn updates and backend test without unknown in packet parser
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-03-01 15:58:58 +01:00
3fc5b6db48 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 15:00:58 +01:00
e9ff417df1 fix 3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-03-01 14:43:59 +01:00
b0d1528142 fix 2
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-03-01 14:42:15 +01:00
2a1c53c419 fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 14:41:47 +01:00
2ec5e21e94 test backend changes api snivver interfaces
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 14:40:52 +01:00
153bd4e7c8 firewall + scripts working
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 13:39:58 +01:00
7c08aed33a api fix and script fe improv
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 11:57:54 +01:00
60634a77eb test script be
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 10:47:48 +01:00
ed54050d6b script improvements
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 10:44:58 +01:00
0dff3ade09 remove double validation
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 09:00:45 +01:00
c48d93642a file blob
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-03-01 08:57:11 +01:00
caf6799dd2 test scripting changes
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 01:21:27 +01:00
8fe9a06bfa start scripting frontend
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-03-01 00:29:05 +01:00
969edeab86 test without handle
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-03-01 00:08:56 +01:00
bff2e62a97 test goood hope
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 00:06:47 +01:00
87bad13729 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-03-01 00:04:58 +01:00
2033d2fce2 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:45:20 +01:00
131d0eabe8 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:40:50 +01:00
1ad3eecee0 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:37:09 +01:00
5a7bce647b test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:34:15 +01:00
1d5750dc00 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:31:24 +01:00
9e31902da6 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:28:28 +01:00
ba23be3742 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:19:20 +01:00
e837937ec0 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 23:16:57 +01:00
3d8a448a25 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 23:13:10 +01:00
1a19a819d8 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 23:11:55 +01:00
50e56be8fd test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 7s
2026-02-28 23:10:14 +01:00
5bd8d86f0d test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 23:08:27 +01:00
32b1d0947f tesstts
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-02-28 23:07:08 +01:00
9af9be92a1 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 23:03:54 +01:00
6183e18045 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 23:00:42 +01:00
5268f8dd4e test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:59:42 +01:00
95d922549d reset
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:47:55 +01:00
2c3ce6bc86 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 22:45:32 +01:00
c247428bf3 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 22:44:00 +01:00
7c5acbf758 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:41:40 +01:00
216f8464f2 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 22:39:31 +01:00
f33c708cce test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:37:59 +01:00
7db2816660 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:35:52 +01:00
cbd7dacd24 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 22:33:14 +01:00
e844d79ac0 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:29:34 +01:00
80352da7fd test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:25:56 +01:00
c3079f3744 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 22:24:39 +01:00
49e6e30a87 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:20:43 +01:00
3a5eb0a46d qdfwd
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 22:11:23 +01:00
5930794a4e nft tables api pretty text
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 22:04:13 +01:00
90da926415 good builder
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-02-28 22:00:20 +01:00
a31ad6d11b rules
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-02-28 20:32:53 +01:00
7e89dcd8a1 GOOD RAW
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 18:41:25 +01:00
64d3a973bc JSON WORKS HERE TAG
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 17:55:03 +01:00
eb1eef23c4 fix position
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 15:56:48 +01:00
b79e5cfbc7 add position to add rule
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 15:46:47 +01:00
6fcff2d042 remove move api
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 15:36:44 +01:00
60fd926fc6 move rule 2026-02-28 15:29:50 +01:00
2c6cb2b7d9 add sort endpoint to nftmanager
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 14:35:22 +01:00
e70167cf91 fix nfqueue
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-28 14:31:26 +01:00
87d950e2ab nfqueue added
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 14:25:08 +01:00
397ec24875 priority best guess fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-28 13:54:34 +01:00
8b1160dff5 add chain properties to api
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-02-28 13:49:35 +01:00
6636f72f11 add chain and table addition to frontend
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-02-23 20:21:16 +01:00
dfa9a4a343 improv FE
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-11 21:56:08 +01:00
0389dd77aa try fixing 400 error when no error
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-11 21:41:14 +01:00
ad94a51214 json api improv
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-11 21:34:41 +01:00
6c5b5ef4a1 nft rule post json
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-11 21:26:58 +01:00
c34b71f5d7 firewall api and FE
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-11 20:39:43 +01:00
d92c168e5c json approach
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-02-11 20:21:39 +01:00
6b68931ba9 test3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-10 21:34:51 +01:00
3fce273343 test2
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-10 21:32:49 +01:00
af9f54637a test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-02-10 21:31:28 +01:00
7d2c891550 nft improve
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-10 21:28:13 +01:00
5ebe16b854 added more types
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-02-10 21:01:43 +01:00
317946c7b7 scripting added
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-02-09 21:56:12 +01:00
69abb95140 fix json encoding
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
2026-02-09 20:36:49 +01:00
7d7008d969 demo
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-29 12:48:05 +01:00
d2649e2ff8 nft improv 3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-29 12:31:01 +01:00
da30e13351 fix status 6
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-28 19:59:48 +01:00
cc3d91a91b fix status 5
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-28 19:57:17 +01:00
815c666d32 fix status 4
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-28 19:55:02 +01:00
621e137d9b fix status 3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-28 19:53:25 +01:00
31edb76e15 fix status 2
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-28 19:30:59 +01:00
a0b1f92418 add delete and fix status
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-28 19:21:40 +01:00
1b4688eacc add delete script
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-28 19:10:00 +01:00
eded798271 venv script improv
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-28 18:43:24 +01:00
90087039f7 scipting improvements
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-28 18:25:17 +01:00
0d66ff2694 add requirements
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-28 18:12:55 +01:00
e01314cf6f add scripting to main.py
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-28 18:10:48 +01:00
02b570d220 add script router
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-01-28 18:09:15 +01:00
86cc1e6e50 test3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-27 17:52:44 +01:00
ae80e967ed test2
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-27 17:37:45 +01:00
8863152202 test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-01-27 17:28:31 +01:00
20c77739ba fix trailing
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-11 17:59:08 +01:00
73a945dbe3 add handke output
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-11 17:54:37 +01:00
80030ec07c wqdqwd
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-11 17:51:28 +01:00
96cf5d573c qwfq
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-11 17:48:06 +01:00
f0790904df qdouwodiu
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-11 17:43:59 +01:00
6347bcafdf fix2124
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-11 17:31:11 +01:00
2c5523d3fe fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-11 17:24:24 +01:00
f422a2979f test12345
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
2026-01-11 17:23:34 +01:00
57de326910 test123
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-11 17:12:19 +01:00
c33c529fbb test fix
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
2026-01-11 16:15:55 +01:00
592bb1b4c4 test123
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
2026-01-11 16:13:55 +01:00
304 changed files with 147711 additions and 1098 deletions

0
.codex Normal file
View File

View File

@@ -3,7 +3,7 @@ name: Build and Deploy MITM Webserver
on: on:
push: push:
branches: branches:
- main # oder der Branch, den du automatisch bauen willst - main
jobs: jobs:
build: build:
@@ -12,8 +12,9 @@ jobs:
- name: Checkout code - name: Checkout code
run: | run: |
cd /opt/mitm-webserver cd /opt/mitm-webserver
git fetch origin main
git checkout main
git reset --hard origin/main git reset --hard origin/main
git pull
- name: Build Frontend - name: Build Frontend
run: | run: |
@@ -32,3 +33,48 @@ jobs:
- name: Restart Backend - name: Restart Backend
run: | run: |
sudo systemctl restart mitm-backend 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
View 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

View File

@@ -2,6 +2,10 @@ FROM python:3.11-slim
WORKDIR /app 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

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

View File

@@ -0,0 +1 @@
netfilterqueue

View 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())

View File

@@ -0,0 +1,2 @@
netfilterqueue
scapy

View 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())

View File

@@ -0,0 +1 @@
netfilterqueue

View 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())

View File

@@ -0,0 +1,2 @@
netfilterqueue
scapy

View 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()

View File

@@ -0,0 +1 @@
netfilterqueue

View 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())

Binary file not shown.

View File

@@ -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 pydantic import BaseModel, Field
from typing import List, Dict, Optional
class Nameservers(BaseModel): class Nameservers(BaseModel):
"""DNS nameserver configuration."""
addresses: List[str] = Field(default_factory=list) addresses: List[str] = Field(default_factory=list)
search: List[str] = Field(default_factory=list) search: List[str] = Field(default_factory=list)
class EthernetConfig(BaseModel): class EthernetConfig(BaseModel):
"""Netplan ethernet interface configuration."""
dhcp4: Optional[bool] = None dhcp4: Optional[bool] = None
dhcp6: Optional[bool] = None dhcp6: Optional[bool] = None
addresses: Optional[List[str]] = None addresses: Optional[List[str]] = None
@@ -18,44 +25,23 @@ class EthernetConfig(BaseModel):
class BridgeConfig(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 dhcp4: Optional[bool] = None
dhcp6: Optional[bool] = None dhcp6: Optional[bool] = None
addresses: Optional[List[str]] = None addresses: Optional[List[str]] = None
gateway4: Optional[str] = None gateway4: Optional[str] = None
gateway6: Optional[str] = None gateway6: Optional[str] = None
nameservers: Optional[Nameservers] = 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 optional: Optional[bool] = None
class NetworkConfig(BaseModel): class NetworkConfig(BaseModel):
"""Top-level Netplan network object."""
version: int = 2 version: int = 2
renderer: Optional[str] = "networkd" renderer: Optional[str] = "networkd"
ethernets: Dict[str, EthernetConfig] = Field(default_factory=dict) ethernets: Dict[str, EthernetConfig] = Field(default_factory=dict)
bridges: Dict[str, BridgeConfig] = 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
}
}
}'''

View 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

View 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)

View File

@@ -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 pydantic import BaseModel, Field
from typing import List, Optional
from pyroute2 import IPRoute, NDB 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() 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 ip: IPRoute | None = None
ndb: NDB | None = None ndb: NDB | None = None
# ------------------------------
# Pydantic models
# ------------------------------
class InterfaceAddress(BaseModel): class InterfaceAddress(BaseModel):
""" """IP address assigned to an interface."""
Represents an IP address assigned to a network interface.
"""
family: str = Field(..., description="IP family: 'ipv4' or 'ipv6'.") family: str = Field(..., description="IP family: 'ipv4' or 'ipv6'.")
address: str = Field(..., description="The IP address assigned to the interface.") address: str = Field(..., description="IP address.")
prefixlen: int = Field(..., description="Subnet prefix length (e.g., 24 for 255.255.255.0).") prefixlen: int = Field(..., description="Subnet prefix length.")
class InterfaceInfo(BaseModel): class InterfaceInfo(BaseModel):
""" """Interface with link metadata and assigned addresses."""
Represents a network interface with all its properties.
""" ifindex: int = Field(..., description="Kernel interface index.")
ifindex: int = Field(..., description="Interface index (unique identifier assigned by the kernel).") name: str = Field(..., description="Interface name.")
name: str = Field(..., description="Interface name (e.g., 'eth0', 'enp38s0').") state: str = Field(..., description="Operational state.")
state: str = Field(..., description="Operational state (e.g., 'UP', 'DOWN', 'UNKNOWN').") mac: Optional[str] = Field(None, description="MAC address.")
mac: Optional[str] = Field(None, description="MAC address of the interface, if applicable.") mtu: int = Field(..., description="Maximum transmission unit.")
mtu: int = Field(..., description="Maximum Transmission Unit for the interface.") flags: List[str] = Field(..., description="Decoded interface flags.")
flags: List[str] = Field(..., description="List of interface flags (e.g., ['BROADCAST', 'MULTICAST']).") ethernet_profile: Optional[Dict[str, Any]] = Field(
addresses: List[InterfaceAddress] = Field(..., description="List of IP addresses assigned to the interface.") None,
description="Current speed/duplex/autoneg profile when available.",
)
addresses: List[InterfaceAddress] = Field(..., description="Assigned IP addresses.")
class RouteInfo(BaseModel): class RouteInfo(BaseModel):
""" """Single routing table entry."""
Represents a single routing table entry.
"""
dst: Optional[str] = Field( dst: Optional[str] = Field(None, description="Destination CIDR; null means default route.")
None, description="Destination network in CIDR notation (e.g., '192.168.1.0/24'). None 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): class BridgeInterfaceInfo(BaseModel):
""" """Interface that belongs to a bridge."""
Represents a network interface which is a member of an bridge.
""" ifindex: int = Field(..., description="Interface index.")
ifindex: int = Field(..., description="Interface index of a bridge member") ifname: str = Field(..., description="Interface name.")
ifname: str = Field(..., description="Interface name of a bridge member") state: Optional[str] = Field(None, description="Operational state.")
state: Optional[str] = Field(None, description="Operational state of the interface") mtu: Optional[int] = Field(None, description="Interface MTU.")
mtu: Optional[int] = Field(None, description="MTU of the interface") ethernet_profile: Optional[Dict[str, Any]] = Field(
None,
description="Current speed/duplex/autoneg profile when available.",
)
class BridgeInfo(BaseModel): class BridgeInfo(BaseModel):
""" """Bridge interface with member information."""
Represents a network bridge interface with all its properties.
""" ifindex: int = Field(..., description="Bridge index.")
ifindex: int = Field(..., description="Interface index of the bridge") ifname: str = Field(..., description="Bridge name.")
ifname: str = Field(..., description="Bridge interface name") state: Optional[str] = Field(None, description="Bridge state.")
state: Optional[str] = Field(None, description="Operational state of the bridge") mtu: Optional[int] = Field(None, description="Bridge MTU.")
mtu: Optional[int] = Field(None, description="MTU of the bridge") stp_state: Optional[int] = Field(None, description="Spanning tree state.")
stp_state: Optional[int] = Field(None, description="STP (Spanning Tree Protocol) state of the bridge") members: List[BridgeInterfaceInfo] = Field(default_factory=list, description="Bridge members.")
members: List[BridgeInterfaceInfo] = Field(default_factory=list, description="List of member interfaces of the bridge")
class BridgeCreateRequest(BaseModel): class BridgeCreateRequest(BaseModel):
"""Payload for creating a bridge and attaching interfaces."""
name: str name: str
interfaces: List[str] interfaces: List[str]
class BridgeRemoveRequest(BaseModel): class BridgeRemoveRequest(BaseModel):
name: str """Payload for removing a bridge."""
# ------------------------------
# Lazy Init Functions
# ------------------------------
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 global ip, ndb
if ip is None: if ip is None:
ip = IPRoute() ip = IPRoute()
if ndb is None: if ndb is None:
ndb = NDB() ndb = NDB()
def shutdown_network_api():
def shutdown_network_api() -> None:
"""Close pyroute2 clients if they were initialized."""
global ip, ndb global ip, ndb
bridge_link_state_manager.stop()
if ip: if ip:
ip.close() ip.close()
ip = None ip = None
@@ -125,37 +202,38 @@ def shutdown_network_api():
ndb.close() ndb.close()
ndb = None ndb = None
def get_iproute():
def get_iproute() -> IPRoute:
"""Dependency provider for the shared IPRoute instance."""
if ip is None: if ip is None:
init_network_api() init_network_api()
return ip return ip
def get_ndb():
def get_ndb() -> NDB:
"""Dependency provider for the shared NDB instance."""
if ndb is None: if ndb is None:
init_network_api() init_network_api()
return ndb return ndb
# ------------------------------
# Utility functions
# ------------------------------
def parse_addresses(addrs): def parse_addresses(addrs: list[dict]) -> list[InterfaceAddress]:
res = [] """Convert pyroute2 address rows into `InterfaceAddress` models."""
for a in addrs: result: list[InterfaceAddress] = []
family = "ipv4" if a.get("family") == 2 else "ipv6" for addr in addrs:
res.append( family = "ipv4" if addr.get("family") == 2 else "ipv6"
result.append(
InterfaceAddress( InterfaceAddress(
family=family, family=family,
address=a.get("address"), address=addr.get("address"),
prefixlen=a.get("prefixlen"), prefixlen=addr.get("prefixlen"),
) )
) )
return res return result
def parse_flags(flags_int: int) -> list[str]: def parse_flags(flags_int: int) -> list[str]:
""" """Decode Linux interface flag bitset to names."""
Converts the integer flags from pyroute2 to human-readable list of strings.
"""
flags_map = { flags_map = {
0x1: "UP", 0x1: "UP",
0x2: "BROADCAST", 0x2: "BROADCAST",
@@ -177,37 +255,104 @@ def parse_flags(flags_int: int) -> list[str]:
0x20000: "DORMANT", 0x20000: "DORMANT",
0x40000: "ECHO", 0x40000: "ECHO",
} }
result = [] return [name for bit, name in flags_map.items() if flags_int & bit]
for bit, name in flags_map.items():
if flags_int & bit:
result.append(name)
return result
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: if not idx:
raise HTTPException(status_code=404, detail=f"Interface {name} not found") raise HTTPException(status_code=404, detail=f"Interface {name} not found")
return idx[0] return idx[0]
def bridge_exists(name: str, ip: IPRoute) -> bool: def bridge_exists(name: str, ip_route: IPRoute) -> bool:
return bool(ip.link_lookup(ifname=name)) """Check whether a bridge/device with the given name exists."""
return bool(ip_route.link_lookup(ifname=name))
# ------------------------------ def _watcher_status_response(payload: dict[str, Any]) -> BridgeLinkStateWatcherStatus:
# Endpoints """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]) @router.get("/interfaces", response_model=List[InterfaceInfo])
def get_interfaces(ip: IPRoute = Depends(get_iproute)): def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
result = [] """List host interfaces with addresses and decoded flags."""
result: list[InterfaceInfo] = []
links = ip.get_links() links = ip.get_links()
addresses = ip.get_addr() addresses = ip.get_addr()
addr_map = {} addr_map: dict[int, list] = {}
for a in addresses: for addr in addresses:
ifindex = a.get("index") ifindex = addr.get("index")
addr_map.setdefault(ifindex, []).append(a) addr_map.setdefault(ifindex, []).append(addr)
for link in links: for link in links:
attrs = dict(link["attrs"]) attrs = dict(link["attrs"])
@@ -222,60 +367,59 @@ def get_interfaces(ip: IPRoute = Depends(get_iproute)):
mac=attrs.get("IFLA_ADDRESS"), mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU"), mtu=attrs.get("IFLA_MTU"),
flags=parse_flags(link.get("flags", 0)), flags=parse_flags(link.get("flags", 0)),
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME")),
addresses=parse_addresses(addrs), addresses=parse_addresses(addrs),
) )
) )
return result return result
@router.get("/routes", response_model=List[RouteInfo]) @router.get("/routes", response_model=List[RouteInfo])
def get_routes(ip: IPRoute = Depends(get_iproute)): def get_routes(ip: IPRoute = Depends(get_iproute)) -> List[RouteInfo]:
routes = [] """List routes from the kernel routing tables."""
for r in ip.get_routes(): routes: list[RouteInfo] = []
attrs = dict(r["attrs"]) for route in ip.get_routes():
attrs = dict(route["attrs"])
dst = attrs.get("RTA_DST") dst = attrs.get("RTA_DST")
gateway = attrs.get("RTA_GATEWAY") gateway = attrs.get("RTA_GATEWAY")
prefsrc = attrs.get("RTA_PREFSRC") prefsrc = attrs.get("RTA_PREFSRC")
oif = r.get("oif") oif = route.get("oif")
ifname = None ifname = None
if oif is not None: if oif is not None:
# translate ifindex → name
link = ip.get_links(oif)[0] link = ip.get_links(oif)[0]
ifname = dict(link["attrs"]).get("IFLA_IFNAME") ifname = dict(link["attrs"]).get("IFLA_IFNAME")
routes.append( routes.append(
RouteInfo( 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, gateway=gateway,
prefsrc=prefsrc, prefsrc=prefsrc,
oif=oif, oif=oif,
ifname=ifname, ifname=ifname,
table=r.get("table", 254), table=route.get("table", 254),
proto=r.get("proto"), proto=route.get("proto"),
scope=r.get("scope"), scope=route.get("scope"),
type=r.get("type"), type=route.get("type"),
) )
) )
return routes return routes
@router.get("/links", response_model=List[InterfaceInfo]) @router.get("/links", response_model=List[InterfaceInfo])
def get_raw_links(ip: IPRoute = Depends(get_iproute)): def get_raw_links(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
""" """List links in a normalized structure for UI consumers."""
Returns all interfaces in a clean Pydantic format. result: list[InterfaceInfo] = []
This is similar to /interfaces but avoids additional processing if needed.
"""
result = []
links = ip.get_links() links = ip.get_links()
addresses = ip.get_addr() addresses = ip.get_addr()
# group addresses by interface index addr_map: dict[int, list] = {}
addr_map = {} for addr in addresses:
for a in addresses: ifindex = addr.get("index")
ifindex = a.get("index") addr_map.setdefault(ifindex, []).append(addr)
addr_map.setdefault(ifindex, []).append(a)
for link in links: for link in links:
attrs = dict(link.get("attrs", [])) # convert list of tuples to dict attrs = dict(link.get("attrs", []))
ifindex = link["index"] ifindex = link["index"]
addrs = addr_map.get(ifindex, []) addrs = addr_map.get(ifindex, [])
@@ -286,116 +430,238 @@ def get_raw_links(ip: IPRoute = Depends(get_iproute)):
state=attrs.get("IFLA_OPERSTATE", "unknown"), state=attrs.get("IFLA_OPERSTATE", "unknown"),
mac=attrs.get("IFLA_ADDRESS"), mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU", 0), 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), addresses=parse_addresses(addrs),
) )
) )
return result 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: @router.get("/bridges", response_model=List[BridgeInfo])
for br in ndb.interfaces: def get_bridges() -> List[BridgeInfo]:
# Only bridges """List all bridges and their current member interfaces."""
if getattr(br, "kind", None) == "bridge": bridges_list: list[BridgeInfo] = []
members: List[BridgeInterfaceInfo] = []
# Find member interfaces with NDB() as ndb_ctx:
for iface in ndb.interfaces: for bridge in ndb_ctx.interfaces:
if getattr(iface, "master", None) == br.index: 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( members.append(
BridgeInterfaceInfo( BridgeInterfaceInfo(
ifindex=iface.index, ifindex=iface.index,
ifname=iface.ifname, ifname=iface.ifname,
state=getattr(iface, "operstate", None), 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( bridges_list.append(
BridgeInfo( BridgeInfo(
ifindex=br.index, ifindex=bridge.index,
ifname=br.ifname, ifname=bridge.ifname,
state=getattr(br, "operstate", None), state=getattr(bridge, "operstate", None),
mtu=getattr(br, "mtu", None), mtu=getattr(bridge, "mtu", None),
stp_state=getattr(br, "stp_state", None), stp_state=getattr(bridge, "stp_state", None),
members=members members=members,
) )
) )
return bridges_list 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), ip: IPRoute = Depends(get_iproute),
): ) -> InterfaceResetDefaultsResponse:
""" """Reset listed interfaces to baseline MTU/autoneg/up settings."""
Returns the full network state: unique_ifaces = list(dict.fromkeys(req.interfaces))
- Interfaces with IP addresses and flags results = [_reset_interface_defaults(ifname, ip) for ifname in unique_ifaces]
- Routes publish_network_state_update("interfaces_reset_defaults")
- Bridges with member interfaces return InterfaceResetDefaultsResponse(results=results)
"""
return {
"interfaces": get_interfaces(ip),
"routes": get_routes(ip),
"bridges": get_bridges(), # uses NDB internally
}
@router.post("/bridge/create") @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): 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") ip.link("add", ifname=req.name, kind="bridge")
br_idx = iface_index(req.name, ip) 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, kind="bridge", br_stp_state=0)
ip.link("set", index=br_idx, state="up") ip.link("set", index=br_idx, state="up")
# Interfaces hinzufügen + aktivieren
for iface in req.interfaces: for iface in req.interfaces:
idx = iface_index(iface, ip) idx = iface_index(iface, ip)
ip.link("set", index=idx, state="down")
# interface hochfahren
ip.link("set", index=idx, state="down") # optional - sicherer
ip.link("set", index=idx, state="up") ip.link("set", index=idx, state="up")
# interface in die bridge hängen
ip.link("set", index=idx, master=br_idx) ip.link("set", index=idx, master=br_idx)
publish_network_state_update("bridge_created")
return { return {
"status": "ok", "status": "ok",
"bridge": req.name, "bridge": req.name,
"interfaces": req.interfaces "interfaces": req.interfaces,
} }
@router.post("/bridge/remove") @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): 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) br_idx = iface_index(req.name, ip)
# Bridge runterfahren
ip.link("set", index=br_idx, state="down") ip.link("set", index=br_idx, state="down")
# Bridge löschen
ip.link("del", index=br_idx) ip.link("del", index=br_idx)
publish_network_state_update("bridge_removed")
return { return {
"status": "ok", "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

536
backend/src/api/nft_api.py Normal file
View File

@@ -0,0 +1,536 @@
# fastapi_nft_router.py
# -*- coding: utf-8 -*-
"""
FastAPI router that lists and manages nftables rules for family 'bridge'
(default table 'mitm_tbl', chain 'forward').
Behavior:
- Uses `nft --json list ruleset` to obtain authoritative rule metadata (handles).
- Uses `nft list chain <family> <table> <chain>` to extract the exact textual
rule lines. Mapping is done by matching `handle N` in the textual output.
- Returns for each rule:
- nft_rule_text_full: exact line from 'nft list chain ...' including 'handle N' (or None)
- nft_rule_text: same line trimmed to remove trailing 'handle N' (or None)
- add_command: "add rule <table> <chain> <nft_rule_text>" (or None)
- No JSON->text reconstruction is attempted. If text mapping is missing we return None.
Security note:
- Process must be run with privileges to run nft (root or appropriate capabilities).
- Consider adding auth before exposing these endpoints.
"""
from typing import Any, Dict, List, Optional, Union, Literal
import subprocess
import shutil
import json
import logging
import re
from enum import Enum
from fastapi import APIRouter, HTTPException, Body
from pydantic import BaseModel, Field, validator
# Router & logging
router = APIRouter()
logger = logging.getLogger("nftables")
logger.debug("nftables router module loaded")
# Defaults & nft binary
DEFAULT_TABLE = "mitm_tbl"
DEFAULT_CHAIN = "forward"
DEFAULT_FAMILY = "bridge"
NFT_BIN = shutil.which("nft")
# ----------------- Enums (for frontend) -----------------
class Family(str, Enum):
bridge = DEFAULT_FAMILY
class Table(str, Enum):
table = DEFAULT_TABLE
class Chain(str, Enum):
forward = "forward"
input = "input"
output = "output"
class MetaKey(str, Enum):
iifname = "iifname"
oifname = "oifname"
iif = "iif"
oif = "oif"
prio = "prio"
class EtherField(str, Enum):
saddr = "saddr"
daddr = "daddr"
class IPDir(str, Enum):
saddr = "saddr"
daddr = "daddr"
class Op(str, Enum):
eq = "=="
neq = "!="
lt = "<"
gt = ">"
contains = "in"
class Verdict(str, Enum):
accept = "accept"
drop = "drop"
reject = "reject"
continue_ = "continue"
class Proto(str, Enum):
tcp = "tcp"
udp = "udp"
icmp = "icmp"
class ConntrackState(str, Enum):
new = "new"
established = "established"
related = "related"
invalid = "invalid"
class RejectType(str, Enum):
icmp = "icmp"
tcp_reset = "tcp reset"
class IcmpType(str, Enum):
dest_unreachable = "destination-unreachable"
time_exceeded = "time-exceeded"
echo_reply = "echo-reply"
echo_request = "echo-request"
port_unreachable = "port-unreachable"
host_unreachable = "host-unreachable"
fragmentation_needed = "fragmentation-needed"
class LogGroup(int, Enum):
g0 = 0
g1 = 1
g2 = 2
g3 = 3
g4 = 4
g5 = 5
g6 = 6
g7 = 7
# ------------ Pydantic expression models (typed for frontend) ----------
class BaseExpr(BaseModel):
kind: str
class Config:
extra = "forbid"
class MetaExpr(BaseExpr):
kind: Literal["meta"] = Field(default="meta")
key: MetaKey
op: Op = Op.eq
value: str
class EtherExpr(BaseExpr):
kind: Literal["ether"] = Field(default="ether")
field: EtherField
op: Op = Op.eq
value: str
class IPExpr(BaseExpr):
kind: Literal["ip"] = Field(default="ip")
side: IPDir
op: Op = Op.eq
value: str
class ProtoPortExpr(BaseExpr):
kind: Literal["l4"] = Field(default="l4")
proto: Proto
sport: Optional[str] = None
dport: Optional[str] = None
class CTEexpr(BaseExpr):
kind: Literal["ct"] = Field(default="ct")
state: ConntrackState
class VerdictExpr(BaseExpr):
kind: Literal["verdict"] = Field(default="verdict")
verdict: Verdict
class RejectExpr(BaseExpr):
kind: Literal["reject"] = Field(default="reject")
reject_type: RejectType
icmp_type: Optional[IcmpType] = None
class LogExpr(BaseExpr):
kind: Literal["log"] = Field(default="log")
prefix: Optional[str] = None
group: Optional[LogGroup] = None
class RawExpr(BaseExpr):
kind: Literal["raw"] = Field(default="raw")
snippet: str
Expr = Union[
MetaExpr, EtherExpr, IPExpr, ProtoPortExpr, CTEexpr,
VerdictExpr, RejectExpr, LogExpr, RawExpr,
]
# ---------------- Rule model ----------------
class RuleModel(BaseModel):
family: Family = Family.bridge
table: Table = Table.table
chain: Chain = Chain.forward
expr: List[Expr] = Field(default_factory=list)
comment: Optional[str] = None
position: Optional[int] = None # 1-based
handle: Optional[int] = None
@validator("family")
def only_bridge(cls, v: Family) -> Family:
if v != Family.bridge:
raise ValueError("This router only manages family 'bridge'")
return v
# ----------------- Helpers --------------------
def ensure_nft_available() -> None:
if not NFT_BIN:
logger.error("nft binary not found on server")
raise HTTPException(status_code=500, detail="nft binary not found on server")
def run_nft_cmd(cmd: str) -> Dict[str, Any]:
"""
Execute a single nft script line via `nft -f -`. Returns stdout/stderr.
"""
ensure_nft_available()
full_cmd = [NFT_BIN, "-f", "-"]
script = cmd.rstrip() + "\n"
logger.info("Running nft command: %s", cmd)
logger.debug("Exec: %s ; script: %s", full_cmd, script)
try:
proc = subprocess.run(full_cmd, input=script.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
stdout = proc.stdout.decode()
stderr = proc.stderr.decode()
logger.info("nft success (stdout %d bytes, stderr %d bytes)", len(stdout), len(stderr))
logger.debug("nft stdout: %s", stdout or "<empty>")
if stderr:
logger.debug("nft stderr: %s", stderr)
return {"stdout": stdout, "stderr": stderr}
except subprocess.CalledProcessError as e:
err = e.stderr.decode() if e.stderr else str(e)
logger.error("nft failed: %s", err)
raise HTTPException(status_code=500, detail=err)
def ensure_table_and_chain_exist(family: str, table: str, chain: str) -> None:
"""
Ensure the named table and chain exist; create them with conservative defaults if missing.
"""
logger.debug("Ensure table/chain exist family=%s table=%s chain=%s", family, table, chain)
ensure_nft_available()
try:
out = subprocess.check_output([NFT_BIN, "--json", "list", "ruleset"], stderr=subprocess.PIPE)
parsed = json.loads(out)
except subprocess.CalledProcessError as e:
logger.error("Failed to list ruleset: %s", e.stderr.decode())
raise HTTPException(status_code=500, detail=f"nft failed: {e.stderr.decode()}")
items = parsed.get("nftables") if isinstance(parsed, dict) else parsed
if not isinstance(items, list):
items = []
table_exists = False
chain_exists = False
for it in items:
if "table" in it:
t = it["table"]
if isinstance(t, dict) and t.get("name") == table and t.get("family") == family:
table_exists = True
if "chain" in it:
ch = it["chain"]
if isinstance(ch, dict) and ch.get("name") == chain and ch.get("table") == table and ch.get("family") == family:
chain_exists = True
if not table_exists:
logger.info("Creating table %s %s", family, table)
run_nft_cmd(f"add table {family} {table}")
if not chain_exists:
logger.info("Creating chain %s in table %s", chain, table)
if chain in ("input", "forward", "output"):
run_nft_cmd(f"add chain {family} {table} {chain} {{ type filter hook {chain} priority 0; policy accept; }}")
else:
run_nft_cmd(f"add chain {family} {table} {chain} {{ policy accept; }}")
# ----------------- Text mapping (strict) -----------------
HANDLE_RE = re.compile(r"\bhandle\s+(\d+)\b", flags=re.IGNORECASE)
def build_handle_text_map(family: str, table: str, chain: str) -> Dict[int, str]:
"""
Runs: nft list chain <family> <table> <chain>
Returns mapping handle -> full textual line containing 'handle N'.
If the textual output cannot be retrieved, raises HTTPException.
"""
ensure_nft_available()
cmd = [NFT_BIN, "--handle", "list", "chain", family, table, chain]
logger.debug("Listing chain text: %s", " ".join(cmd))
try:
out = subprocess.check_output(cmd, stderr=subprocess.PIPE).decode()
except subprocess.CalledProcessError as e:
logger.error("Failed to list chain text: %s", e.stderr.decode())
raise HTTPException(status_code=500, detail=e.stderr.decode())
mapping: Dict[int, str] = {}
for line in out.splitlines():
s = line.strip()
if not s:
continue
m = HANDLE_RE.search(s)
if not m:
continue
try:
h = int(m.group(1))
# full textual line as-is
mapping[h] = s
logger.debug("Found textual rule for handle %d: %s", h, s)
except Exception as ex:
logger.debug("Failed parsing handle from line: %s (%s)", s, ex)
continue
return mapping
# ----------------- Rules listing (JSON + strict textual lookup) -----------------
def nft_list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""
Return rules parsed from nft --json list ruleset, augmented with textual lines
extracted from `nft list chain <family> <table> <chain>` via handle matching.
For each rule returned:
- family, table, chain
- handle
- position (1-based in chain)
- comment (best-effort from JSON exprs)
- verdict (best-effort)
- exprs (the JSON expr list)
- nft_rule_text_full: exact textual line from nft list chain ... INCLUDING 'handle N' (or None)
- nft_rule_text: textual line trimmed to remove trailing 'handle N' (or None)
- add_command: "add rule <table> <chain> <nft_rule_text>" (or None)
"""
ensure_nft_available()
# 1) JSON dump: authoritative structure
try:
out = subprocess.check_output([NFT_BIN, "--json", "list", "ruleset"], stderr=subprocess.PIPE)
parsed = json.loads(out)
except subprocess.CalledProcessError as e:
logger.error("Failed to get JSON ruleset: %s", e.stderr.decode())
raise HTTPException(status_code=500, detail=e.stderr.decode())
# 2) textual map: strict mapping by handle
text_map: Dict[int, str] = {}
try:
text_map = build_handle_text_map(DEFAULT_FAMILY, table, chain)
logger.debug("Text map size: %d", len(text_map))
except HTTPException as e:
# bubble up the error: user asked to extract exact textual lines and we couldn't get them
logger.error("Failed to obtain textual chain dump: %s", getattr(e, "detail", str(e)))
# still continue — per your request we won't attempt reconstructions, but we can return None textual fields.
text_map = {}
results: List[Dict[str, Any]] = []
counters: Dict[str, int] = {}
items = parsed.get("nftables") if isinstance(parsed, dict) else parsed
if not isinstance(items, list):
items = []
for it in items:
if "rule" not in it:
continue
r = it["rule"]
family = r.get("family")
table_name = r.get("table")
chain_name = r.get("chain")
# only return rules for requested table/chain
if table_name != table or chain_name != chain:
continue
key = f"{family}:{table_name}:{chain_name}"
counters.setdefault(key, 0)
counters[key] += 1
position = counters[key]
handle = r.get("handle")
exprs = r.get("expr", [])
# best-effort comment + verdict extraction from JSON exprs (keeps UI useful)
comment: Optional[str] = None
verdict: Optional[str] = None
for ex in exprs:
if not isinstance(ex, dict):
continue
if "comment" in ex:
c = ex.get("comment")
if isinstance(c, str):
comment = c
elif isinstance(c, dict):
comment = c.get("text") or c.get("str")
if "verdict" in ex:
v = ex["verdict"]
if isinstance(v, dict):
verdict = next(iter(v.keys()), None)
else:
verdict = str(v)
if "drop" in ex and verdict is None:
verdict = "drop"
if "accept" in ex and verdict is None:
verdict = "accept"
if "reject" in ex and verdict is None:
verdict = "reject"
# strict textual lookup: only use exact line if present in text_map
nft_rule_text_full: Optional[str] = None
nft_rule_text: Optional[str] = None
add_command: Optional[str] = None
if handle is not None and handle in text_map:
nft_rule_text_full = text_map[handle]
# remove trailing ' handle N' to get copy/paste clause
m = HANDLE_RE.search(nft_rule_text_full)
if m:
# slice everything before ' handle N'
raw_clause = nft_rule_text_full[: m.start()].strip()
else:
raw_clause = nft_rule_text_full
# remove a trailing lone '#' (and surrounding whitespace) if present
# e.g. "meta iifname \"eth0\" # " -> "meta iifname \"eth0\""
nft_rule_text = re.sub(r"\s*#\s*$", "", raw_clause).strip()
add_command = f"add rule {table_name} {chain_name} {nft_rule_text}".strip() if nft_rule_text else None
logger.debug("Attached textual rule for handle %s", handle)
else:
logger.debug("No textual mapping for handle %s — textual fields will be None", handle)
results.append({
"family": family,
"table": table_name,
"chain": chain_name,
"handle": handle,
"position": position,
"comment": comment,
"verdict": verdict,
"exprs": exprs,
"nft_rule_text_full": nft_rule_text_full,
"nft_rule_text": nft_rule_text,
"add_command": add_command,
})
return {"rules": results}
# ------------ Expr -> nft snippet & command builder (preview/add) ------
def expr_to_nft_snippet(e: Expr) -> str:
"""Build short nft snippet from typed Expr (used for preview/add)."""
if isinstance(e, MetaExpr):
val = e.value
key = e.key.value
return f"meta {key} {e.op.value} {val}"
if isinstance(e, EtherExpr):
return f"ether {e.field.value} {e.op.value} {e.value}"
if isinstance(e, IPExpr):
return f"ip {e.side.value} {e.op.value} {e.value}"
if isinstance(e, ProtoPortExpr):
parts = [e.proto.value]
if e.sport:
parts.append(f"sport {e.sport}")
if e.dport:
parts.append(f"dport {e.dport}")
return " ".join(parts)
if isinstance(e, CTEexpr):
return f"ct state {e.state.value}"
if isinstance(e, VerdictExpr):
return e.verdict.value if e.verdict != Verdict.continue_ else "continue"
if isinstance(e, RejectExpr):
if e.reject_type == RejectType.icmp:
return f"reject with icmp type {e.icmp_type.value}" if e.icmp_type else "reject"
return e.reject_type.value
if isinstance(e, LogExpr):
parts = ["log"]
if e.prefix:
parts.append(f'prefix "{e.prefix}"')
if e.group is not None:
parts.append(f"group {int(e.group)}")
return " ".join(parts)
if isinstance(e, RawExpr):
return e.snippet
raise ValueError("Unsupported expression type")
def rule_to_nft_cmd(rule: RuleModel) -> str:
expr_snips = [expr_to_nft_snippet(e) for e in rule.expr]
body = " ".join(s for s in expr_snips if s)
if rule.position is not None:
cmd = f"insert rule {rule.table.value} {rule.chain.value} position {rule.position} {body}"
else:
cmd = f"add rule {rule.table.value} {rule.chain.value} {body}"
if rule.comment:
cmd += f' comment "{rule.comment}"'
return cmd
# ---------------- Endpoints -------------------
@router.get("/options")
def get_options() -> Dict[str, Any]:
"""Return enum choices for frontend dropdowns."""
return {
"family": [f.value for f in Family],
"table": [t.value for t in Table],
"chain": [c.value for c in Chain],
"meta_keys": [m.value for m in MetaKey],
"ether_fields": [e.value for e in EtherField],
"ip_dirs": [d.value for d in IPDir],
"ops": [o.value for o in Op],
"verdicts": [v.value for v in Verdict],
"protocols": [p.value for p in Proto],
"ct_states": [s.value for s in ConntrackState],
"reject_types": [r.value for r in RejectType],
"icmp_types": [i.value for i in IcmpType],
"log_groups": [int(g.value) for g in LogGroup],
}
@router.get("/rules")
def list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""List rules for the given table/chain (ensures table/chain exist first)."""
ensure_table_and_chain_exist(DEFAULT_FAMILY, table, chain)
return nft_list_rules(table=table, chain=chain)
@router.post("/rules/preview")
def preview_rule(rule: RuleModel = Body(...)) -> Dict[str, str]:
"""Return the nft command that would be executed for the provided rule (preview only)."""
try:
cmd = rule_to_nft_cmd(rule)
except Exception as e:
logger.error("Preview build failed: %s", e)
raise HTTPException(status_code=400, detail=str(e))
return {"cmd": cmd}
@router.post("/rules")
def add_rule(rule: RuleModel = Body(...)) -> Dict[str, Any]:
"""Insert/append rule (creates table/chain if missing)."""
ensure_table_and_chain_exist(rule.family.value, rule.table.value, rule.chain.value)
cmd = rule_to_nft_cmd(rule)
return run_nft_cmd(cmd)
@router.delete("/rules/{handle}")
def delete_rule(handle: int, table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""Delete rule by nft handle."""
ensure_table_and_chain_exist(DEFAULT_FAMILY, table, chain)
cmd = f"delete rule {table} {chain} handle {handle}"
return run_nft_cmd(cmd)
@router.put("/rules/{handle}")
def update_rule(handle: int, rule: RuleModel = Body(...), table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""Replace a rule by handle: delete by handle then insert replacement (attempt to preserve position)."""
ensure_table_and_chain_exist(rule.family.value, rule.table.value, rule.chain.value)
rules_info = nft_list_rules(table=table, chain=chain)
position: Optional[int] = None
for r in rules_info.get("rules", []):
if r.get("handle") == handle:
position = r.get("position")
break
delete_rule(handle, table=table, chain=chain)
if position is not None:
rule.position = position
return add_rule(rule)

View File

@@ -0,0 +1,531 @@
# app.py
from typing import Any, Dict, List, Optional, Tuple, Union
from fastapi import FastAPI, APIRouter, HTTPException, status
from pydantic import BaseModel, Field, ValidationError
import logging
import json
import re
# libnftables (we call textual commands through its .cmd() method)
from nftables import Nftables # type: ignore
# ---------- logging ----------
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("nft_api_raw_only")
# ---------- Exceptions ----------
class NftError(RuntimeError):
pass
# ---------- NftManager (textual-only) ----------
class NftManager:
def __init__(self) -> None:
self.nft = Nftables()
try:
# prefer JSON output globally where available
self.nft.set_json_output(True)
self.nft.set_handle_output(True)
except Exception:
logger.debug("set_json_output/set_handle_output not available")
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
rc, out, err = self.nft.cmd(text_cmd)
if rc != 0:
logger.warning("nft cmd rc=%s stderr=%s cmd=%s", rc, err, text_cmd)
return {"rc": rc, "stdout": out, "stderr": err}
def list_rules_json(self) -> Dict[str, Any]:
res = self.cmd("list ruleset")
if res["rc"] != 0:
raise NftError(f"nft list ruleset failed: {res['stderr']}")
out = res["stdout"]
if not out:
raise NftError("nft list ruleset returned empty output")
try:
return json.loads(out)
except json.JSONDecodeError as e:
raise NftError(f"json decode error: {e}")
def list_rules_text(self) -> str:
# best-effort: temporarily disable JSON output so we get textual form
json_toggled = False
try:
if hasattr(self.nft, "set_json_output"):
try:
self.nft.set_json_output(False)
json_toggled = True
except Exception:
logger.debug("could not toggle set_json_output(False)")
res = self.cmd("list ruleset")
finally:
if json_toggled and hasattr(self.nft, "set_json_output"):
try:
self.nft.set_json_output(True)
except Exception:
logger.debug("could not restore set_json_output(True)")
if res["rc"] != 0:
raise NftError(f"nft list ruleset failed: {res['stderr']}")
return res["stdout"] or ""
def delete_rule_by_handle_text(self, family: str, table: str, chain: str, handle: int) -> None:
if not isinstance(handle, int) or handle <= 0:
raise ValueError("handle must be a positive integer")
cmd = f"delete rule {family} {table} {chain} handle {handle}"
res = self.cmd(cmd)
if res["rc"] != 0:
raise NftError(f"delete rule failed: {res['stderr']}")
# ---------- FastAPI + Router ----------
app = FastAPI(title="Unrestricted nftables API (json create)")
router = APIRouter(prefix="/firewall", tags=["firewall"])
mgr = NftManager()
# ---------- Models ----------
class RawCmdRequest(BaseModel):
cmd: str = Field(..., description="Textual nft command to execute")
class ExecResult(BaseModel):
rc: int = Field(..., description="Return code from nft execution")
stdout: Optional[str] = Field(None)
stderr: Optional[str] = Field(None)
class RuleOut(BaseModel):
handle: Optional[int] = Field(None)
expr: Any = Field(..., description="Machine-readable nft expression (original nft JSON expr).")
text: str = Field(..., description="Deterministic short display string derived from expr")
position: Optional[Any] = Field(None)
comment: Optional[str] = Field(None)
class ChainOut(BaseModel):
name: str = Field(...)
type: Optional[str] = Field(None)
hook: Optional[str] = Field(None)
priority: Optional[int] = Field(None)
policy: Optional[str] = Field(None)
rules: List[RuleOut] = Field(...)
class TableOut(BaseModel):
family: str = Field(...)
name: str = Field(...)
chains: List[ChainOut] = Field(...)
class RulesetModel(BaseModel):
tables: List[TableOut] = Field(...)
class CreateRuleRequest(BaseModel):
family: str
table: str
chain: str
expr: Any
position: Optional[int]
comment: Optional[str]
RulesetValue = Optional[Union[RulesetModel, str]]
class RulesetOut(BaseModel):
ruleset: RulesetValue
# ---------- Helpers ----------
def parse_priority(val: Any) -> Optional[int]:
if val is None:
return None
if isinstance(val, int):
return val
if isinstance(val, str):
s = val.strip()
try:
return int(s)
except Exception:
try:
return int(float(s))
except Exception:
return None
if isinstance(val, dict):
for key in ("priority", "prio"):
if key in val:
return parse_priority(val.get(key))
for v in val.values():
p = parse_priority(v)
if p is not None:
return p
return None
def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert nft -j list ruleset parsed JSON into a deterministic, predictable JSON:
"""
result: Dict[str, Any] = {"tables": []}
items = nft_json.get("nftables", []) if isinstance(nft_json, dict) else (nft_json or [])
tables: Dict[Tuple[str, str], Dict[str, Any]] = {}
for rec in items:
if "table" in rec:
t = rec["table"]
fam = t.get("family")
name = t.get("name")
if fam and name:
tables.setdefault((fam, name), {"family": fam, "name": name, "chains": {}})
elif "chain" in rec:
ch = rec["chain"]
fam = ch.get("family") or (ch.get("table") or {}).get("family")
table_name = ch.get("table") or (ch.get("table") or {}).get("name")
cname = ch.get("name")
if fam and table_name and cname:
tables.setdefault((fam, table_name), {"family": fam, "name": table_name, "chains": {}})
chains_map = tables[(fam, table_name)]["chains"]
existing = chains_map.get(cname)
ch_type = ch.get("type")
ch_hook = ch.get("hook")
ch_priority = parse_priority(ch.get("priority") if "priority" in ch else ch.get("prio") if "prio" in ch else ch.get("priority", None))
if ch_priority is None:
ch_priority = parse_priority(ch.get("hook") if isinstance(ch.get("hook"), dict) else None)
ch_policy = ch.get("policy")
if existing is None:
chains_map[cname] = {
"name": cname,
"type": ch_type,
"hook": ch_hook,
"priority": ch_priority,
"policy": ch_policy,
"rules": [],
}
else:
if isinstance(existing, dict):
if existing.get("type") is None and ch_type is not None:
existing["type"] = ch_type
if existing.get("hook") is None and ch_hook is not None:
existing["hook"] = ch_hook
if existing.get("priority") is None and ch_priority is not None:
existing["priority"] = ch_priority
if existing.get("policy") is None and ch_policy is not None:
existing["policy"] = ch_policy
elif "rule" in rec:
r = rec["rule"]
fam = r.get("family")
table_name = r.get("table")
chain_name = r.get("chain")
handle = r.get("handle")
expr = r.get("expr")
if fam and table_name and chain_name:
tables.setdefault((fam, table_name), {"family": fam, "name": table_name, "chains": {}})
chains_map = tables[(fam, table_name)]["chains"]
chains_map.setdefault(chain_name, {"name": chain_name, "type": None, "hook": None, "priority": None, "policy": None, "rules": []})
# do NOT change expr shape here; keep it exactly as NFT JSON provided
rule_obj: Dict[str, Any] = {"handle": handle, "expr": expr, "text": ""}
if "position" in r:
rule_obj["position"] = r["position"]
if "comment" in r:
rule_obj["comment"] = r["comment"]
chains_map[chain_name]["rules"].append(rule_obj)
# salvage chain-level metadata from rule record if present
if isinstance(r.get("chain"), dict):
csub = r.get("chain")
if chains_map[chain_name].get("priority") is None:
parsed_prio = parse_priority(csub.get("priority") if "priority" in csub else csub.get("prio"))
if parsed_prio is not None:
chains_map[chain_name]["priority"] = parsed_prio
if chains_map[chain_name].get("type") is None and csub.get("type") is not None:
chains_map[chain_name]["type"] = csub.get("type")
if chains_map[chain_name].get("hook") is None and csub.get("hook") is not None:
chains_map[chain_name]["hook"] = csub.get("hook")
if chains_map[chain_name].get("policy") is None and csub.get("policy") is not None:
chains_map[chain_name]["policy"] = csub.get("policy")
# convert to lists (deterministic order)
for (fam, tname) in sorted(tables.keys(), key=lambda k: (k[0], k[1])):
tdata = tables[(fam, tname)]
chains_list: List[Dict[str, Any]] = []
for cname in sorted(tdata["chains"].keys()):
chdata = tdata["chains"][cname]
chains_list.append(
{
"name": chdata.get("name"),
"type": chdata.get("type"),
"hook": chdata.get("hook"),
"priority": chdata.get("priority"),
"policy": chdata.get("policy"),
"rules": chdata.get("rules", []),
}
)
result["tables"].append({"family": fam, "name": tname, "chains": chains_list})
return result
# ---------- Text parsing helpers (enrichment only) ----------
def parse_ruleset_text(nft_text: str) -> Dict[Tuple[str, str, str], List[Dict[str, Optional[Union[str, int]]]]]:
result: Dict[Tuple[str, str, str], List[Dict[str, Optional[Union[str, int]]]]] = {}
if not nft_text:
return result
table_re = re.compile(r"^\s*table\s+(\S+)\s+(\S+)\s*\{")
chain_re = re.compile(r"^\s*chain\s+(\S+)\s*\{")
handle_re = re.compile(r"#\s*handle\s*(\d+)\b")
# only skip semicolon-terminated chain metadata lines (type/hook/priority/policy)
chain_meta_re = re.compile(r"^\s*(type\b|hook\b|priority\b|policy\b)\b.*;")
current_family = None
current_table = None
current_chain = None
for raw_ln in nft_text.splitlines():
ln = raw_ln.rstrip("\n")
s = ln.strip()
m_table = table_re.match(ln)
if m_table:
current_family = m_table.group(1)
current_table = m_table.group(2)
current_chain = None
continue
m_chain = chain_re.match(ln)
if m_chain and current_family and current_table:
current_chain = m_chain.group(1)
key = (current_family, current_table, current_chain)
result.setdefault(key, [])
continue
if current_family and current_table and current_chain:
if s == "" or s == "{" or s == "}":
continue
if chain_meta_re.match(s):
# skip chain metadata lines only
continue
m_handle = handle_re.search(s)
handle_val: Optional[int] = None
if m_handle:
try:
handle_val = int(m_handle.group(1))
except Exception:
handle_val = None
key = (current_family, current_table, current_chain)
result.setdefault(key, []).append({"line": ln.strip(), "handle": handle_val})
return result
def clean_rule_line(line: str) -> str:
"""
Remove trailing ' # handle <num>' from a textual rule line.
This strips the handle comment part while preserving the rest of the line.
"""
if not line:
return line
# remove ' # handle 123' optionally preceded by spaces and possibly at end-of-line
cleaned = re.sub(r"\s+#\s*handle\s*\d+\b\s*$", "", line)
return cleaned
def populate_text_from_ruleset_text(custom: Dict[str, Any], nft_text: str) -> None:
"""
Enrich JSON-derived 'custom' structure in-place by setting only rule['text']
when a reliable textual mapping is found. Do not change expr or other types.
"""
if not nft_text:
return
parsed = parse_ruleset_text(nft_text)
for table in custom.get("tables", []):
fam = table.get("family")
tname = table.get("name")
if not fam or not tname:
continue
for chain in table.get("chains", []):
cname = chain.get("name")
if not cname:
continue
key = (fam, tname, cname)
textual_entries = parsed.get(key, [])
if not textual_entries:
continue
handle_map: Dict[int, str] = {}
ordered_lines: List[str] = []
for ent in textual_entries:
ln = ent.get("line") or ""
h = ent.get("handle")
ordered_lines.append(ln)
if isinstance(h, int):
handle_map[h] = ln
rules = chain.get("rules", [])
for idx, rule in enumerate(rules):
# ONLY update 'text' when we can map a textual line
h = rule.get("handle")
mapped: Optional[str] = None
if isinstance(h, int) and h in handle_map:
mapped = handle_map[h]
else:
pos = rule.get("position")
if isinstance(pos, int) and 0 <= pos < len(ordered_lines):
mapped = ordered_lines[pos]
elif idx < len(ordered_lines):
mapped = ordered_lines[idx]
# final substring probe (safe)
if mapped is None:
probe = rule.get("text")
if probe:
for ln in ordered_lines:
if probe in ln:
mapped = ln
break
if mapped is not None:
# clean the handle fragment from the textual line before storing
try:
cleaned = clean_rule_line(str(mapped))
rule["text"] = cleaned
except Exception:
rule["text"] = mapped # fallback (should be str)
# ---------- Normalization helper (lightweight and safe) ----------
def normalize_custom_for_model(custom: Dict[str, Any]) -> None:
"""
Make minimal, safe guarantees required by Pydantic:
- rule['expr'] must exist (if None -> set to empty list)
- rule['text'] must be a str (if missing -> derived string)
- rule['handle'] coerced to int or None
Do NOT change any other shapes.
"""
for t in custom.get("tables", []):
for ch in t.get("chains", []):
rules = ch.get("rules", []) or []
for r in rules:
# expr: if missing or None => set to [] (preserves Any)
if "expr" not in r or r.get("expr") is None:
r["expr"] = []
# text: ensure string
if "text" not in r or r.get("text") is None:
r["text"] = ""
else:
if not isinstance(r["text"], str):
try:
r["text"] = str(r["text"])
except Exception:
r["text"] = ""
# handle: coerce to int or None
h = r.get("handle")
if isinstance(h, str):
try:
r["handle"] = int(h)
except Exception:
r["handle"] = None
elif isinstance(h, float):
try:
r["handle"] = int(h)
except Exception:
r["handle"] = None
elif not isinstance(h, int):
r["handle"] = None
# ---------- Routes ----------
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
def list_rules():
"""
Returns JSON-derived ruleset (RulesetModel) and enriches each rule['text']
with the textual nft rule line when possible. This function will not replace
JSON-derived 'expr' or other data types — enrichment is additive only.
"""
try:
try:
nft_json = mgr.list_rules_json()
except NftError as e:
logger.debug("could not obtain nft JSON ruleset: %s", e)
raise HTTPException(status_code=500, detail=f"Failed to obtain nft JSON ruleset: {e}")
# best-effort textual snapshot for enrichment
nft_text = ""
try:
nft_text = mgr.list_rules_text()
except Exception:
logger.debug("could not obtain textual nft ruleset snapshot")
# Build canonical JSON-derived shape (source of truth)
custom = build_predictable_ruleset(nft_json)
# Enrich only the 'text' field in-place using the textual snapshot
try:
if nft_text:
populate_text_from_ruleset_text(custom, nft_text)
except Exception as e:
logger.debug("populate_text_from_ruleset_text failed: %s", e)
# Normalize minimally for model validation
normalize_custom_for_model(custom)
# Debug counts
num_tables = len(custom.get("tables", []))
num_rules = sum(len(ch.get("rules", [])) for t in custom.get("tables", []) for ch in t.get("chains", []))
logger.info("list_rules: returning tables=%d rules=%d", num_tables, num_rules)
# RETURN a shape matching response_model=RulesetOut
try:
ruleset_model = RulesetModel.parse_obj(custom)
except ValidationError as ve:
# log full validation error for debugging and return 500 with message
logger.exception("RulesetModel validation failed: %s", ve)
raise HTTPException(status_code=500, detail=f"Internal: ruleset validation failed: {ve}")
return {"ruleset": ruleset_model}
except NftError as e:
logger.exception("list_rules failed")
raise HTTPException(status_code=500, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.exception("list_rules internal error")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete rule by handle")
def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain: str = "input"):
try:
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
except ValueError as e:
logger.warning("delete_rule client error: %s", e)
raise HTTPException(status_code=400, detail=str(e))
except NftError as e:
logger.exception("delete_rule failed")
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
logger.exception("delete_rule internal error")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/raw", response_model=ExecResult, summary="Execute raw textual nft command")
def exec_raw(req: RawCmdRequest):
try:
res = mgr.cmd(req.cmd)
rc = int(res.get("rc", -1) or -1)
return ExecResult(rc=rc, stdout=res.get("stdout"), stderr=res.get("stderr"))
except Exception as e:
logger.exception("exec_raw failed")
raise HTTPException(status_code=500, detail=str(e))
app.include_router(router)

View File

@@ -1,49 +1,73 @@
# src/routers/packets.py """Packet history and streaming endpoints."""
import asyncio import asyncio
import base64 import base64
import json import json
import logging 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 fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.websockets import WebSocketState
import src.shared_objects as shared import src.shared_objects as shared
from src.Models.packets import PacketDBModel
logger = logging.getLogger("packets_router") logger = logging.getLogger("packets_router")
router = APIRouter() router = APIRouter()
def _serialize_row_for_json(row: Dict[str, Any]) -> Dict[str, Any]: def _serialize_row_for_json(row: Union[Dict[str, Any], PacketDBModel, BaseModel]) -> Dict[str, Any]:
""" """Convert one packet row to a JSON-safe dictionary."""
Convert DB row / pkt_info to a JSON-serializable dict. if isinstance(row, BaseModel):
- If 'raw' is bytes, produce 'raw_b64' and drop 'raw'. raw_dict: Dict[str, Any] = row.dict(by_alias=True, exclude_none=True)
- Fallback to str() for unknown/unserializable values. else:
""" raw_dict = dict(row)
out: Dict[str, Any] = {}
for k, v in row.items(): raw_val = raw_dict.get("raw")
if k == "raw" and isinstance(v, (bytes, bytearray)): if raw_val is not None and isinstance(raw_val, (bytes, bytearray)):
out["raw_b64"] = base64.b64encode(v).decode("ascii")
continue
# try to JSON serialize the value directly
try: try:
json.dumps({k: v}) raw_dict["raw_b64"] = base64.b64encode(raw_val).decode("ascii")
out[k] = v 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): except (TypeError, ValueError):
out[k] = str(v) try:
return out output[key] = str(value)
except Exception:
output[key] = "<unserializable>"
return output
async def _serialize_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: async def _serialize_rows(rows: List[Union[Dict[str, Any], PacketDBModel]]) -> List[Dict[str, Any]]:
return [_serialize_row_for_json(r) for r in rows] """Convert packet rows to JSON-safe dictionaries, preserving order."""
return [_serialize_row_for_json(row) for row in rows]
@router.get("/packets") @router.get("/packets")
async def get_packets(limit: int = Query(100, ge=1, le=10000)): async def get_packets(limit: int = Query(100, ge=1, le=10000)) -> JSONResponse:
""" """Return the latest packets in reverse chronological order."""
Return latest `limit` packets (newest first). The DB helper already converts
`raw` to `raw_b64` in fetch_latest, but we defensively re-serialize here.
"""
db = shared.db db = shared.db
if db is None: if db is None:
logger.warning("GET /packets called but DB is not available") 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: try:
rows = await db.fetch_latest(limit) rows = await db.fetch_latest(limit)
serial = await _serialize_rows(rows) serialized = await _serialize_rows(rows)
return JSONResponse(content={"count": len(serial), "packets": serial}) return JSONResponse(content={"count": len(serialized), "packets": serialized})
except Exception: except Exception as exc:
logger.exception("Failed to fetch latest packets from DB") 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") @router.websocket("/ws/packets")
async def websocket_packets(ws: WebSocket): async def websocket_packets(ws: WebSocket) -> None:
""" """Stream live packets to a websocket client."""
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.
"""
await ws.accept() await ws.accept()
logger.debug("WebSocket connection accepted: %s", ws.client) 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") logger.warning("WebSocket closed: broadcaster not available")
return return
# Parse subscribe_recent from query params (defensive)
try: try:
subscribe_recent_raw = ws.query_params.get("subscribe_recent", "0") subscribe_recent_raw = ws.query_params.get("subscribe_recent", "0")
subscribe_recent = int(subscribe_recent_raw) subscribe_recent = int(subscribe_recent_raw)
if subscribe_recent < 0: subscribe_recent = max(subscribe_recent, 0)
subscribe_recent = 0
except Exception: except Exception:
subscribe_recent = 0 subscribe_recent = 0
q: Optional[asyncio.Queue] = None queue: Optional[asyncio.Queue] = None
try: try:
# Optionally send recent history first
if subscribe_recent > 0: if subscribe_recent > 0:
recent = await db.fetch_latest(subscribe_recent) recent = await db.fetch_latest(subscribe_recent)
recent_serial = await _serialize_rows(recent) recent_serialized = await _serialize_rows(recent)
await ws.send_json({"type": "recent", "count": len(recent_serial), "packets": recent_serial}) await ws.send_json({"type": "recent", "count": len(recent_serialized), "packets": recent_serialized})
# Subscribe to broadcaster to receive live packets queue = await broadcaster.subscribe()
q = await broadcaster.subscribe() logger.info("WebSocket subscribed client %s (queue maxsize=%d)", ws.client, queue.maxsize)
logger.info("WebSocket subscribed client %s (queue maxsize=%d)", ws.client, q.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: while True:
msg = await q.get() queue_task = asyncio.create_task(queue.get())
# Normalize message to JSON-able dict receive_task = asyncio.create_task(ws.receive())
if isinstance(msg, dict): done, pending = await asyncio.wait(
payload = _serialize_row_for_json(msg) {queue_task, receive_task},
else: return_when=asyncio.FIRST_COMPLETED,
# not a dict — try to json-serialize directly )
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
if receive_task in done:
try: try:
json.dumps(msg) inbound = receive_task.result()
payload = msg except WebSocketDisconnect:
logger.info("WebSocket client disconnected: %s", ws.client)
break
except Exception: 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: try:
await ws.send_json(payload) await ws.send_json(payload)
except Exception: 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 break
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info("WebSocket client disconnected: %s", ws.client) logger.info("WebSocket client disconnected: %s", ws.client)
except Exception: except Exception:
logger.exception("Unexpected error in websocket_packets") logger.exception("Unexpected error in websocket_packets")
finally: finally:
# Clean up subscriber queue if queue is not None:
if q is not None:
try: try:
await broadcaster.unsubscribe(q) await broadcaster.unsubscribe(queue)
except Exception: except Exception:
logger.exception("Failed to unsubscribe websocket queue") logger.exception("Failed to unsubscribe websocket queue")
try: try:
await ws.close() await ws.close()
except Exception: except Exception:
pass 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,
}
)

File diff suppressed because it is too large Load Diff

View File

@@ -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 pydantic import BaseModel, Field
from typing import Dict, Any, List, Optional
from src.network_sniffer import ( from src.network_sniffer import (
get_sniffer_status, BRIDGE_CAPTURE_MODE_AF_PACKET,
start_afpacket_sniffer, BRIDGE_CAPTURE_MODE_TC_EBPF,
stop_afpacket_sniffer, get_internal_debug_state,
get_capture_session_status,
start_capture_session,
stop_capture_session,
) )
router = APIRouter() router = APIRouter()
# ------------------------------
# Pydantic Models
# ------------------------------
class SnifferStartRequest(BaseModel): class SnifferStartRequest(BaseModel):
""" """Request payload for starting a packet capture session."""
Request model for starting the sniffer on a specific bridge.
"""
bridge: str = Field(..., example="br0", description="Name of the Linux bridge to sniff on")
bridge: Optional[str] = Field(
class SnifferStartResponse(BaseModel): None,
""" example="br0",
Response model returned when sniffer starts successfully. description="Bridge name to sniff.",
""" )
started: bool = Field(..., description="Whether the sniffer was started successfully") interface: Optional[str] = Field(
bridge: str = Field(..., description="Bridge where the sniffer was started") None,
example="eth0",
description="Interface name to sniff.",
class SnifferStopResponse(BaseModel): )
""" bridge_capture_mode: Optional[str] = Field(
Response model returned when the sniffer stops successfully. None,
""" example="tc_ebpf",
stopped: bool = Field(..., description="Whether the sniffer was stopped successfully") description="Bridge capture mode: 'tc_ebpf' or 'af_packet'. Ignored for interface capture.",
)
benchmark_mode: bool = Field(
class InterfaceSnifferStatus(BaseModel): False,
""" description=(
Status of an individual interface monitored by the AF_PACKET sniffer. "Run capture hooks for measurement while skipping packet parsing, DB persistence, "
""" "DPI enrichment, and live packet publication."
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"
) )
# ------------------------------ class SnifferStartResponse(BaseModel):
# Endpoints """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) @router.post("/start", response_model=SnifferStartResponse)
def sniffer_start(req: SnifferStartRequest): def sniffer_start(req: SnifferStartRequest) -> SnifferStartResponse:
""" """Start one packet capture session for exactly one target."""
Start the AF_PACKET sniffer for the given bridge. if bool(req.bridge) == bool(req.interface):
""" raise HTTPException(status_code=400, detail="Exactly one of 'bridge' or 'interface' must be provided")
try: try:
start_afpacket_sniffer(req.bridge) if req.interface:
return SnifferStartResponse(started=True, bridge=req.bridge) 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: 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) @router.post("/stop", response_model=SnifferStopResponse)
def sniffer_stop(): def sniffer_stop(
""" q_bridge: Optional[str] = Query(
Stop the AF_PACKET sniffer (if running). 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: try:
stop_afpacket_sniffer() stop_capture_session(session_id=body.session_id)
return SnifferStopResponse(stopped=True) return SnifferStopResponse(stopped=True, session_id=body.session_id, target=None, target_type=None)
except Exception as exc: 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) @router.get("/status", response_model=SnifferStatusResponse)
def sniffer_status(): def sniffer_status() -> SnifferStatusResponse:
""" """Return current capture-session status per interface."""
Return the sniffer status information.
"""
try: try:
raw = get_sniffer_status() raw: Dict[str, Dict[str, Any]] = get_capture_session_status()
# Convert raw dict → typed model typed = {key: InterfaceSnifferStatus(**value) for key, value in raw.items()}
typed = {
k: InterfaceSnifferStatus(**v)
for k, v in raw.items()
}
return SnifferStatusResponse(interfaces=typed) return SnifferStatusResponse(interfaces=typed)
except Exception as exc: 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
View 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()

View File

@@ -1,27 +1,29 @@
# src/main.py """FastAPI application entrypoint and runtime wiring."""
import asyncio import asyncio
import logging import logging
import os import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from src.api import packet_api
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.network_api as network_api
import src.api.sniffer_api as sniffer_api import src.api.sniffer_api as sniffer_api
import src.api.nftables_api as nftables_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
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 ----------------------------------------------------------- logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.DEBUG))
DB_DSN = "postgresql://mitm_user:mitm_password@localhost:5432/mitm_db" shared_objects.db = DatabasePool(
settings.db_dsn,
min_size=settings.db_pool_min_size,
logging.basicConfig(level=logging.DEBUG) max_size=settings.db_pool_max_size,
)
# ---- Globals -----------------------------------------------
# Create DatabasePool instance (pool created on startup)
shared_objects.db = DatabasePool(DB_DSN)
app = FastAPI( app = FastAPI(
root_path="/api", root_path="/api",
@@ -41,62 +43,89 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ---------------------
# Startup / Shutdown
# ---------------------
@app.on_event("startup") @app.on_event("startup")
async def on_startup(): async def on_startup() -> None:
""" """Initialize shared runtime objects on the FastAPI event loop."""
Initialize DB pool and broadcaster on the FastAPI event loop and
publish them into shared_objects so other modules (sniffer, routers)
can access them.
"""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
shared_objects.web_loop = loop shared_objects.web_loop = loop
# Initialize DB pool bound to this loop
try: try:
await shared_objects.db.init_pool() await shared_objects.db.init_pool()
except Exception: except Exception:
logging.exception("Failed to initialize DB pool") logging.exception("Failed to initialize DB pool")
raise raise
# Create broadcaster and attach to DB so DB.insert_packet can publish updates
try: try:
shared_objects.broadcaster = PacketBroadcaster(loop) shared_objects.broadcaster = PacketBroadcaster(loop, queue_maxsize=settings.broadcaster_queue_maxsize)
shared_objects.db.broadcaster = shared_objects.broadcaster shared_objects.db.broadcaster = shared_objects.broadcaster
except Exception: except Exception:
logging.exception("Failed to create/attach broadcaster") 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: 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 from src import network_sniffer as sniffer
# sniffer provides drain_buffer_to_shared_db()
try: try:
sniffer.drain_buffer_to_shared_db() sniffer.drain_buffer_to_shared_db()
except Exception: except Exception:
logging.exception("Failed to drain sniffer buffer") logging.exception("Failed to drain sniffer buffer")
except ImportError: 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") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event() -> None:
""" """Stop network resources and release shared runtime objects."""
Shutdown actions: stop network API and close DB pool if present. try:
""" from src.network_sniffer import stop_capture_session
# try to shut down network API components
stop_capture_session()
except Exception:
logging.exception("Failed to stop capture sessions during shutdown")
try: try:
network_api.shutdown_network_api() network_api.shutdown_network_api()
except Exception: except Exception:
logging.exception("Error shutting down network API") 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: try:
web_db = getattr(shared_objects, "db", None) web_db = getattr(shared_objects, "db", None)
if web_db is not None: if web_db is not None:
@@ -104,34 +133,29 @@ async def shutdown_event():
except Exception: except Exception:
logging.exception("Failed to close DB pool during shutdown") logging.exception("Failed to close DB pool during shutdown")
# clear shared runtime objects (optional cleanup)
try:
shared_objects.db = None shared_objects.db = None
shared_objects.broadcaster = None shared_objects.broadcaster = None
shared_objects.network_broadcaster = None
shared_objects.web_loop = None shared_objects.web_loop = None
except Exception:
pass
# ---------------------
# Basic Endpoints
# ---------------------
@app.get("/hello") @app.get("/hello")
def hello(): def hello() -> dict[str, str]:
"""Simple health-check endpoint."""
return {"message": "Hello from FastAPI 🎉"} return {"message": "Hello from FastAPI 🎉"}
@app.get("/versions") @app.get("/versions")
def versions(): def versions() -> dict[str, str]:
"""Return runtime Python version."""
message = os.popen("python --version").read().strip() message = os.popen("python --version").read().strip()
return {"message": message} return {"message": message}
# ---------------------
# Routers
# ---------------------
app.include_router(network_api.router, prefix="/network", tags=["network"]) app.include_router(network_api.router, prefix="/network", tags=["network"])
app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"]) app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"])
app.include_router(packet_api.router, prefix="/packets", tags=["packets"]) app.include_router(packet_api.router, prefix="/packets", tags=["packets"])
app.include_router(nftables_api.router, prefix="/nftables", tags=["nftables"]) 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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
from typing import Optional """Shared runtime objects initialized during FastAPI startup."""
import asyncio
# These are filled at FastAPI startup import asyncio
# DB instance from typing import Any, Optional
db = None
db: Any = None
web_loop: Optional[asyncio.AbstractEventLoop] = None web_loop: Optional[asyncio.AbstractEventLoop] = None
broadcaster = None broadcaster: Any = None
network_broadcaster: Any = None

View 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()

View 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()

File diff suppressed because it is too large Load Diff

View 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(&eth, 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())

View File

@@ -1,9 +1,15 @@
from typing import List, Dict, Optional from typing import Any, Dict, List, Optional
import errno
import logging import logging
import os import os
import subprocess
import time
# ---- Logging ---------------------------------------------------------- # ---- 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 # Interface / bridge helpers
@@ -30,6 +36,133 @@ def check_interface_up(iface: str) -> bool:
return False 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]: def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]:
""" """
Read bridge member interfaces from sysfs. Internal helper that always reads. Read bridge member interfaces from sysfs. Internal helper that always reads.

View File

@@ -1,140 +1,103 @@
# src/utilities/packet_broadcaster.py """In-process packet broadcaster for websocket subscribers."""
import asyncio import asyncio
import logging import logging
from typing import Dict, Any, List, Optional from typing import Any, Dict, List, Optional
logger = logging.getLogger("packet_broadcaster") logger = logging.getLogger("packet_broadcaster")
_SHUTDOWN_SENTINEL: Dict[str, Any] = {"type": "__broadcaster_shutdown__"}
class PacketBroadcaster: class PacketBroadcaster:
""" """Manage subscriber queues and publish packet events."""
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.
"""
def __init__(self, loop: asyncio.AbstractEventLoop, queue_maxsize: int = 1024): def __init__(self, loop: asyncio.AbstractEventLoop, queue_maxsize: int = 1024):
self._loop = loop self._loop = loop
self._queue_maxsize = queue_maxsize self._queue_maxsize = queue_maxsize
# create lock and subscribers on the target loop to avoid cross-loop asyncio primitives
self._subscribers: List[asyncio.Queue] = [] 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._lock: Optional[asyncio.Lock] = None
self._closed = False
try: try:
# ensure lock is created on the given loop def _make_lock() -> None:
def _make_lock():
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
loop.call_soon_threadsafe(_make_lock) loop.call_soon_threadsafe(_make_lock)
except Exception: except Exception:
# fallback — create in current loop if call_soon_threadsafe fails
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._closed = False
async def subscribe(self) -> asyncio.Queue: async def subscribe(self) -> asyncio.Queue:
""" """Create and register a queue for one subscriber."""
Create a subscriber queue and add it to the list.
Caller is expected to await on the returned queue to receive messages.
"""
if self._closed: if self._closed:
raise RuntimeError("PacketBroadcaster is closed") raise RuntimeError("PacketBroadcaster is closed")
q: asyncio.Queue = asyncio.Queue(maxsize=self._queue_maxsize) queue: asyncio.Queue = asyncio.Queue(maxsize=self._queue_maxsize)
# wait until lock exists
while self._lock is None: while self._lock is None:
await asyncio.sleep(0) # yield to event loop briefly await asyncio.sleep(0)
async with self._lock: async with self._lock:
self._subscribers.append(q) self._subscribers.append(queue)
return q
async def unsubscribe(self, q: asyncio.Queue) -> None: return queue
"""
Remove a subscriber queue if present. async def unsubscribe(self, queue: asyncio.Queue) -> None:
""" """Unregister a subscriber queue if it exists."""
if self._lock is None: if self._lock is None:
return return
async with self._lock: async with self._lock:
try: try:
self._subscribers.remove(q) self._subscribers.remove(queue)
except ValueError: except ValueError:
pass pass
async def publish(self, msg: Dict[str, Any]) -> None: async def publish(self, msg: Dict[str, Any]) -> None:
""" """Publish one message to all current subscribers."""
Publish msg to all subscribers (must be called on the broadcaster's loop). if self._closed or self._lock is None:
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
return return
async with self._lock: async with self._lock:
subs = list(self._subscribers) subscribers = list(self._subscribers)
for q in subs: for queue in subscribers:
try: try:
q.put_nowait(msg) queue.put_nowait(msg)
except asyncio.QueueFull: except asyncio.QueueFull:
# drop message for this subscriber
continue continue
except Exception as exc: except Exception as exc:
logger.exception("Unexpected error when publishing to subscriber: %s", exc) logger.exception("Unexpected subscriber publish error: %s", exc)
# attempt to remove broken subscriber
try: try:
async with self._lock: async with self._lock:
if q in self._subscribers: if queue in self._subscribers:
self._subscribers.remove(q) self._subscribers.remove(queue)
except Exception: except Exception:
pass pass
def sync_publish(self, msg: Dict[str, Any]) -> None: def sync_publish(self, msg: Dict[str, Any]) -> None:
""" """Thread-safe wrapper that schedules `publish` on the broadcaster loop."""
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.
"""
if self._closed: if self._closed:
return return
try: try:
# schedule the coroutine to run on the broadcaster loop
self._loop.call_soon_threadsafe(asyncio.create_task, self.publish(msg)) self._loop.call_soon_threadsafe(asyncio.create_task, self.publish(msg))
except Exception as exc: except Exception as exc:
# swallow errors but log for debugging
logger.exception("sync_publish failed to schedule publish: %s", exc) logger.exception("sync_publish failed to schedule publish: %s", exc)
async def close(self) -> None: async def close(self) -> None:
""" """Close the broadcaster and clear queued messages."""
Close the broadcaster: mark closed, clear subscribers, and drain queues.
"""
self._closed = True self._closed = True
if self._lock is None: if self._lock is None:
return return
async with self._lock: async with self._lock:
subs = list(self._subscribers) subscribers = list(self._subscribers)
self._subscribers.clear() self._subscribers.clear()
for q in subs: for queue in subscribers:
try: try:
# optionally notify subscribers of closure by putting None (client must handle) while not queue.empty():
# q.put_nowait(None) queue.get_nowait()
while not q.empty(): queue.put_nowait(_SHUTDOWN_SENTINEL)
try:
q.get_nowait()
except Exception:
break
except Exception: except Exception:
pass pass

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

View 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

View 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,
)

File diff suppressed because it is too large Load Diff

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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
View 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

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

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

View 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\\

View 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},
}

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

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

View 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.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -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,
1 setup category metric unit direction payload_size_bytes protocol count mean median min max std
2 bridge-new flent data_file_exists bool 3 1.0 1.0 1.0 1.0 0.0
3 bridge-new iperf_tcp retransmits count forward 1 0.0 0.0 0.0 0.0
4 bridge-new iperf_tcp retransmits count reverse 1 0.0 0.0 0.0 0.0
5 bridge-new iperf_tcp throughput Mbit/s forward 1 941.2170773518191 941.2170773518191 941.2170773518191 941.2170773518191
6 bridge-new iperf_tcp throughput Mbit/s reverse 1 941.223044856063 941.223044856063 941.223044856063 941.223044856063
7 bridge-new iperf_udp jitter ms forward 1 329.1133542566476 329.1133542566476 329.1133542566476 329.1133542566476
8 bridge-new iperf_udp jitter ms reverse 1 0.011945675546752457 0.011945675546752457 0.011945675546752457 0.011945675546752457
9 bridge-new iperf_udp loss percent forward 1 0.00552541229203311 0.00552541229203311 0.00552541229203311 0.00552541229203311
10 bridge-new iperf_udp loss percent reverse 1 0.0 0.0 0.0 0.0
11 bridge-new iperf_udp throughput Mbit/s forward 1 691.7975032635362 691.7975032635362 691.7975032635362 691.7975032635362
12 bridge-new iperf_udp throughput Mbit/s reverse 1 899.980891114048 899.980891114048 899.980891114048 899.980891114048
13 bridge-new ping jitter_mean_abs_delta ms 56.0 1 0.25014242848569707 0.25014242848569707 0.25014242848569707 0.25014242848569707
14 bridge-new ping jitter_mean_abs_delta ms 512.0 1 0.20838367673534708 0.20838367673534708 0.20838367673534708 0.20838367673534708
15 bridge-new ping jitter_mean_abs_delta ms 1472.0 1 0.18928945789157833 0.18928945789157833 0.18928945789157833 0.18928945789157833
16 bridge-new ping loss percent 56.0 1 0.0 0.0 0.0 0.0
17 bridge-new ping loss percent 512.0 1 0.0 0.0 0.0 0.0
18 bridge-new ping loss percent 1472.0 1 0.0 0.0 0.0 0.0
19 bridge-new ping rtt_iqr ms 56.0 1 0.20999999999999974 0.20999999999999974 0.20999999999999974 0.20999999999999974
20 bridge-new ping rtt_iqr ms 512.0 1 0.17000000000000015 0.17000000000000015 0.17000000000000015 0.17000000000000015
21 bridge-new ping rtt_iqr ms 1472.0 1 0.15999999999999992 0.15999999999999992 0.15999999999999992 0.15999999999999992
22 bridge-new ping rtt_mad ms 56.0 1 0.06999999999999984 0.06999999999999984 0.06999999999999984 0.06999999999999984
23 bridge-new ping rtt_mad ms 512.0 1 0.050000000000000266 0.050000000000000266 0.050000000000000266 0.050000000000000266
24 bridge-new ping rtt_mad ms 1472.0 1 0.040000000000000036 0.040000000000000036 0.040000000000000036 0.040000000000000036
25 bridge-new ping rtt_max ms 56.0 1 2.24 2.24 2.24 2.24
26 bridge-new ping rtt_max ms 512.0 1 2.31 2.31 2.31 2.31
27 bridge-new ping rtt_max ms 1472.0 1 2.36 2.36 2.36 2.36
28 bridge-new ping rtt_mean ms 56.0 1 1.7996464 1.7996464 1.7996464 1.7996464
29 bridge-new ping rtt_mean ms 512.0 1 1.866984 1.866984 1.866984 1.866984
30 bridge-new ping rtt_mean ms 1472.0 1 1.910105 1.910105 1.910105 1.910105
31 bridge-new ping rtt_median ms 56.0 1 1.98 1.98 1.98 1.98
32 bridge-new ping rtt_median ms 512.0 1 2.03 2.03 2.03 2.03
33 bridge-new ping rtt_median ms 1472.0 1 2.08 2.08 2.08 2.08
34 bridge-new ping rtt_min ms 56.0 1 0.279 0.279 0.279 0.279
35 bridge-new ping rtt_min ms 512.0 1 0.306 0.306 0.306 0.306
36 bridge-new ping rtt_min ms 1472.0 1 0.373 0.373 0.373 0.373
37 bridge-new ping rtt_p95 ms 56.0 1 2.09 2.09 2.09 2.09
38 bridge-new ping rtt_p95 ms 512.0 1 2.13 2.13 2.13 2.13
39 bridge-new ping rtt_p95 ms 1472.0 1 2.18 2.18 2.18 2.18
40 bridge-new ping rtt_p99 ms 56.0 1 2.14 2.14 2.14 2.14
41 bridge-new ping rtt_p99 ms 512.0 1 2.17 2.17 2.17 2.17
42 bridge-new ping rtt_p99 ms 1472.0 1 2.21 2.21 2.21 2.21
43 bridge-new ping rtt_stdev ms 56.0 1 0.42052572542496 0.42052572542496 0.42052572542496 0.42052572542496
44 bridge-new ping rtt_stdev ms 512.0 1 0.38826014131180947 0.38826014131180947 0.38826014131180947 0.38826014131180947
45 bridge-new ping rtt_stdev ms 1472.0 1 0.40225082364120024 0.40225082364120024 0.40225082364120024 0.40225082364120024
46 bridge-new sockperf avg_latency_usec us tcp 1 242.02 242.02 242.02 242.02
47 direct flent data_file_exists bool 3 1.0 1.0 1.0 1.0 0.0
48 direct iperf_tcp retransmits count forward 1 0.0 0.0 0.0 0.0
49 direct iperf_tcp retransmits count reverse 1 0.0 0.0 0.0 0.0
50 direct iperf_tcp throughput Mbit/s forward 1 941.4052500378058 941.4052500378058 941.4052500378058 941.4052500378058
51 direct iperf_tcp throughput Mbit/s reverse 1 941.4233862520524 941.4233862520524 941.4233862520524 941.4233862520524
52 direct iperf_udp jitter ms forward 1 0.010443516671235937 0.010443516671235937 0.010443516671235937 0.010443516671235937
53 direct iperf_udp jitter ms reverse 1 0.010410725838564765 0.010410725838564765 0.010410725838564765 0.010410725838564765
54 direct iperf_udp loss percent forward 1 0.0 0.0 0.0 0.0
55 direct iperf_udp loss percent reverse 1 0.002895969068476154 0.002895969068476154 0.002895969068476154 0.002895969068476154
56 direct iperf_udp throughput Mbit/s forward 1 899.951785108759 899.951785108759 899.951785108759 899.951785108759
57 direct iperf_udp throughput Mbit/s reverse 1 899.961104896446 899.961104896446 899.961104896446 899.961104896446
58 direct ping jitter_mean_abs_delta ms 56.0 1 0.16798879775955192 0.16798879775955192 0.16798879775955192 0.16798879775955192
59 direct ping jitter_mean_abs_delta ms 512.0 1 0.18655691138227648 0.18655691138227648 0.18655691138227648 0.18655691138227648
60 direct ping jitter_mean_abs_delta ms 1472.0 1 0.18377075415083016 0.18377075415083016 0.18377075415083016 0.18377075415083016
61 direct ping loss percent 56.0 1 0.0 0.0 0.0 0.0
62 direct ping loss percent 512.0 1 0.0 0.0 0.0 0.0
63 direct ping loss percent 1472.0 1 0.0 0.0 0.0 0.0
64 direct ping rtt_iqr ms 56.0 1 0.14000000000000012 0.14000000000000012 0.14000000000000012 0.14000000000000012
65 direct ping rtt_iqr ms 512.0 1 0.16000000000000014 0.16000000000000014 0.16000000000000014 0.16000000000000014
66 direct ping rtt_iqr ms 1472.0 1 0.1200000000000001 0.1200000000000001 0.1200000000000001 0.1200000000000001
67 direct ping rtt_mad ms 56.0 1 0.040000000000000036 0.040000000000000036 0.040000000000000036 0.040000000000000036
68 direct ping rtt_mad ms 512.0 1 0.08000000000000007 0.08000000000000007 0.08000000000000007 0.08000000000000007
69 direct ping rtt_mad ms 1472.0 1 0.05999999999999983 0.05999999999999983 0.05999999999999983 0.05999999999999983
70 direct ping rtt_max ms 56.0 1 1.74 1.74 1.74 1.74
71 direct ping rtt_max ms 512.0 1 1.78 1.78 1.78 1.78
72 direct ping rtt_max ms 1472.0 1 1.81 1.81 1.81 1.81
73 direct ping rtt_mean ms 56.0 1 1.3608360000000002 1.3608360000000002 1.3608360000000002 1.3608360000000002
74 direct ping rtt_mean ms 512.0 1 1.3263896000000002 1.3263896000000002 1.3263896000000002 1.3263896000000002
75 direct ping rtt_mean ms 1472.0 1 1.335693 1.335693 1.335693 1.335693
76 direct ping rtt_median ms 56.0 1 1.51 1.51 1.51 1.51
77 direct ping rtt_median ms 512.0 1 1.48 1.48 1.48 1.48
78 direct ping rtt_median ms 1472.0 1 1.43 1.43 1.43 1.43
79 direct ping rtt_min ms 56.0 1 0.027 0.027 0.027 0.027
80 direct ping rtt_min ms 512.0 1 0.043 0.043 0.043 0.043
81 direct ping rtt_min ms 1472.0 1 0.077 0.077 0.077 0.077
82 direct ping rtt_p95 ms 56.0 1 1.59 1.59 1.59 1.59
83 direct ping rtt_p95 ms 512.0 1 1.6 1.6 1.6 1.6
84 direct ping rtt_p95 ms 1472.0 1 1.63 1.63 1.63 1.63
85 direct ping rtt_p99 ms 56.0 1 1.66 1.66 1.66 1.66
86 direct ping rtt_p99 ms 512.0 1 1.65 1.65 1.65 1.65
87 direct ping rtt_p99 ms 1472.0 1 1.68 1.68 1.68 1.68
88 direct ping rtt_stdev ms 56.0 1 0.38241341543944507 0.38241341543944507 0.38241341543944507 0.38241341543944507
89 direct ping rtt_stdev ms 512.0 1 0.41370645730808175 0.41370645730808175 0.41370645730808175 0.41370645730808175
90 direct ping rtt_stdev ms 1472.0 1 0.36380108603059363 0.36380108603059363 0.36380108603059363 0.36380108603059363
91 direct sockperf avg_latency_usec us tcp 1 21.286 21.286 21.286 21.286

View 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
1 setup category metric value unit direction payload_size_bytes protocol test source
2 direct flent data_file_exists 1.0 bool rrul measurments/20260508-221054-direct/summary.json
3 direct flent data_file_exists 1.0 bool tcp_upload measurments/20260508-221054-direct/summary.json
4 direct flent data_file_exists 1.0 bool tcp_download measurments/20260508-221054-direct/summary.json
5 direct iperf_tcp retransmits 0.0 count forward measurments/20260508-221054-direct/summary.json
6 direct iperf_tcp retransmits 0.0 count reverse measurments/20260508-221054-direct/summary.json
7 direct iperf_tcp throughput 941.4052500378058 Mbit/s forward measurments/20260508-221054-direct/summary.json
8 direct iperf_tcp throughput 941.4233862520524 Mbit/s reverse measurments/20260508-221054-direct/summary.json
9 direct iperf_udp jitter 0.010443516671235937 ms forward measurments/20260508-221054-direct/summary.json
10 direct iperf_udp jitter 0.010410725838564765 ms reverse measurments/20260508-221054-direct/summary.json
11 direct iperf_udp loss 0.0 percent forward measurments/20260508-221054-direct/summary.json
12 direct iperf_udp loss 0.002895969068476154 percent reverse measurments/20260508-221054-direct/summary.json
13 direct iperf_udp throughput 899.951785108759 Mbit/s forward measurments/20260508-221054-direct/summary.json
14 direct iperf_udp throughput 899.961104896446 Mbit/s reverse measurments/20260508-221054-direct/summary.json
15 direct ping jitter_mean_abs_delta 0.16798879775955192 ms 56.0 measurments/20260508-221054-direct/summary.json
16 direct ping jitter_mean_abs_delta 0.18655691138227648 ms 512.0 measurments/20260508-221054-direct/summary.json
17 direct ping jitter_mean_abs_delta 0.18377075415083016 ms 1472.0 measurments/20260508-221054-direct/summary.json
18 direct ping loss 0.0 percent 56.0 measurments/20260508-221054-direct/summary.json
19 direct ping loss 0.0 percent 512.0 measurments/20260508-221054-direct/summary.json
20 direct ping loss 0.0 percent 1472.0 measurments/20260508-221054-direct/summary.json
21 direct ping rtt_iqr 0.14000000000000012 ms 56.0 measurments/20260508-221054-direct/summary.json
22 direct ping rtt_iqr 0.16000000000000014 ms 512.0 measurments/20260508-221054-direct/summary.json
23 direct ping rtt_iqr 0.1200000000000001 ms 1472.0 measurments/20260508-221054-direct/summary.json
24 direct ping rtt_mad 0.040000000000000036 ms 56.0 measurments/20260508-221054-direct/summary.json
25 direct ping rtt_mad 0.08000000000000007 ms 512.0 measurments/20260508-221054-direct/summary.json
26 direct ping rtt_mad 0.05999999999999983 ms 1472.0 measurments/20260508-221054-direct/summary.json
27 direct ping rtt_max 1.74 ms 56.0 measurments/20260508-221054-direct/summary.json
28 direct ping rtt_max 1.78 ms 512.0 measurments/20260508-221054-direct/summary.json
29 direct ping rtt_max 1.81 ms 1472.0 measurments/20260508-221054-direct/summary.json
30 direct ping rtt_mean 1.3608360000000002 ms 56.0 measurments/20260508-221054-direct/summary.json
31 direct ping rtt_mean 1.3263896000000002 ms 512.0 measurments/20260508-221054-direct/summary.json
32 direct ping rtt_mean 1.335693 ms 1472.0 measurments/20260508-221054-direct/summary.json
33 direct ping rtt_median 1.51 ms 56.0 measurments/20260508-221054-direct/summary.json
34 direct ping rtt_median 1.48 ms 512.0 measurments/20260508-221054-direct/summary.json
35 direct ping rtt_median 1.43 ms 1472.0 measurments/20260508-221054-direct/summary.json
36 direct ping rtt_min 0.027 ms 56.0 measurments/20260508-221054-direct/summary.json
37 direct ping rtt_min 0.043 ms 512.0 measurments/20260508-221054-direct/summary.json
38 direct ping rtt_min 0.077 ms 1472.0 measurments/20260508-221054-direct/summary.json
39 direct ping rtt_p95 1.59 ms 56.0 measurments/20260508-221054-direct/summary.json
40 direct ping rtt_p95 1.6 ms 512.0 measurments/20260508-221054-direct/summary.json
41 direct ping rtt_p95 1.63 ms 1472.0 measurments/20260508-221054-direct/summary.json
42 direct ping rtt_p99 1.66 ms 56.0 measurments/20260508-221054-direct/summary.json
43 direct ping rtt_p99 1.65 ms 512.0 measurments/20260508-221054-direct/summary.json
44 direct ping rtt_p99 1.68 ms 1472.0 measurments/20260508-221054-direct/summary.json
45 direct ping rtt_stdev 0.38241341543944507 ms 56.0 measurments/20260508-221054-direct/summary.json
46 direct ping rtt_stdev 0.41370645730808175 ms 512.0 measurments/20260508-221054-direct/summary.json
47 direct ping rtt_stdev 0.36380108603059363 ms 1472.0 measurments/20260508-221054-direct/summary.json
48 direct sockperf avg_latency_usec 21.286 us tcp measurments/20260508-221054-direct/summary.json
49 bridge-new flent data_file_exists 1.0 bool rrul measurments/20260508-215553-bridge-new/summary.json
50 bridge-new flent data_file_exists 1.0 bool tcp_upload measurments/20260508-215553-bridge-new/summary.json
51 bridge-new flent data_file_exists 1.0 bool tcp_download measurments/20260508-215553-bridge-new/summary.json
52 bridge-new iperf_tcp retransmits 0.0 count forward measurments/20260508-215553-bridge-new/summary.json
53 bridge-new iperf_tcp retransmits 0.0 count reverse measurments/20260508-215553-bridge-new/summary.json
54 bridge-new iperf_tcp throughput 941.2170773518191 Mbit/s forward measurments/20260508-215553-bridge-new/summary.json
55 bridge-new iperf_tcp throughput 941.223044856063 Mbit/s reverse measurments/20260508-215553-bridge-new/summary.json
56 bridge-new iperf_udp jitter 329.1133542566476 ms forward measurments/20260508-215553-bridge-new/summary.json
57 bridge-new iperf_udp jitter 0.011945675546752457 ms reverse measurments/20260508-215553-bridge-new/summary.json
58 bridge-new iperf_udp loss 0.00552541229203311 percent forward measurments/20260508-215553-bridge-new/summary.json
59 bridge-new iperf_udp loss 0.0 percent reverse measurments/20260508-215553-bridge-new/summary.json
60 bridge-new iperf_udp throughput 691.7975032635362 Mbit/s forward measurments/20260508-215553-bridge-new/summary.json
61 bridge-new iperf_udp throughput 899.980891114048 Mbit/s reverse measurments/20260508-215553-bridge-new/summary.json
62 bridge-new ping jitter_mean_abs_delta 0.25014242848569707 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
63 bridge-new ping jitter_mean_abs_delta 0.20838367673534708 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
64 bridge-new ping jitter_mean_abs_delta 0.18928945789157833 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
65 bridge-new ping loss 0.0 percent 56.0 measurments/20260508-215553-bridge-new/summary.json
66 bridge-new ping loss 0.0 percent 512.0 measurments/20260508-215553-bridge-new/summary.json
67 bridge-new ping loss 0.0 percent 1472.0 measurments/20260508-215553-bridge-new/summary.json
68 bridge-new ping rtt_iqr 0.20999999999999974 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
69 bridge-new ping rtt_iqr 0.17000000000000015 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
70 bridge-new ping rtt_iqr 0.15999999999999992 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
71 bridge-new ping rtt_mad 0.06999999999999984 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
72 bridge-new ping rtt_mad 0.050000000000000266 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
73 bridge-new ping rtt_mad 0.040000000000000036 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
74 bridge-new ping rtt_max 2.24 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
75 bridge-new ping rtt_max 2.31 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
76 bridge-new ping rtt_max 2.36 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
77 bridge-new ping rtt_mean 1.7996464 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
78 bridge-new ping rtt_mean 1.866984 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
79 bridge-new ping rtt_mean 1.910105 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
80 bridge-new ping rtt_median 1.98 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
81 bridge-new ping rtt_median 2.03 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
82 bridge-new ping rtt_median 2.08 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
83 bridge-new ping rtt_min 0.279 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
84 bridge-new ping rtt_min 0.306 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
85 bridge-new ping rtt_min 0.373 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
86 bridge-new ping rtt_p95 2.09 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
87 bridge-new ping rtt_p95 2.13 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
88 bridge-new ping rtt_p95 2.18 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
89 bridge-new ping rtt_p99 2.14 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
90 bridge-new ping rtt_p99 2.17 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
91 bridge-new ping rtt_p99 2.21 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
92 bridge-new ping rtt_stdev 0.42052572542496 ms 56.0 measurments/20260508-215553-bridge-new/summary.json
93 bridge-new ping rtt_stdev 0.38826014131180947 ms 512.0 measurments/20260508-215553-bridge-new/summary.json
94 bridge-new ping rtt_stdev 0.40225082364120024 ms 1472.0 measurments/20260508-215553-bridge-new/summary.json
95 bridge-new sockperf avg_latency_usec 242.02 us tcp measurments/20260508-215553-bridge-new/summary.json

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 32 KiB

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

View 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.

View 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.

View File

@@ -11,15 +11,22 @@
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@types/d3": "^7.4.3",
"@types/d3-sankey": "^0.12.5",
"antd": "^6.0.0", "antd": "^6.0.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4" "react-router-dom": "^7.9.4",
"react-simple-code-editor": "^0.14.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/node": "^24.9.1", "@types/node": "^24.9.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
@@ -2277,6 +2284,283 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2284,6 +2568,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/history": {
"version": "4.7.11", "version": "4.7.11",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
@@ -2316,6 +2606,13 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
@@ -2875,6 +3172,15 @@
"node": ">= 0.8" "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": { "node_modules/compute-scroll-into-view": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
@@ -2960,6 +3266,448 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3082,6 +3830,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -4067,9 +4824,7 @@
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
@@ -4143,6 +4898,15 @@
"node": ">= 0.4" "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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5204,6 +5968,15 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -5305,6 +6078,16 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/react-simple-code-editor": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5380,6 +6163,12 @@
"node": ">=4" "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": { "node_modules/rollup": {
"version": "4.53.5", "version": "4.53.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
@@ -5422,6 +6211,12 @@
"fsevents": "~2.3.2" "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": { "node_modules/safe-array-concat": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -5481,9 +6276,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true, "license": "MIT"
"license": "MIT",
"optional": true
}, },
"node_modules/sax": { "node_modules/sax": {
"version": "1.4.3", "version": "1.4.3",

View File

@@ -15,15 +15,22 @@
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@types/d3": "^7.4.3",
"@types/d3-sankey": "^0.12.5",
"antd": "^6.0.0", "antd": "^6.0.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4" "react-router-dom": "^7.9.4",
"react-simple-code-editor": "^0.14.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/node": "^24.9.1", "@types/node": "^24.9.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",

View File

@@ -4,3 +4,19 @@
padding: 2rem; padding: 2rem;
text-align: center; 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;
}

View File

@@ -1,4 +1,4 @@
import { Layout } from 'antd'; import { Layout, notification } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import './App.css'; import './App.css';
@@ -8,6 +8,14 @@ import Sidebar from './Layout/Sidebar';
const { Content, Footer } = Layout; 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() { export default function App() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
return ( return (

View File

@@ -1,5 +1,5 @@
import { Footer } from 'antd/es/layout/layout'; import { Footer } from 'antd/es/layout/layout';
export const AppFooter: React.FC = () => { 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>;
}; };

View File

@@ -1,8 +1,17 @@
// src/components/Sidebar.tsx // 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 { Layout, Menu } from 'antd';
import React from 'react'; import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import FirewallIcon from '../icons/FirewallIcon';
import TerminalIcon from '../icons/TerminalIcon';
import { PATHS } from '../routes'; import { PATHS } from '../routes';
import '../theme/layout.less'; import '../theme/layout.less';
@@ -11,7 +20,14 @@ const { Sider } = Layout;
const menuItems = [ const menuItems = [
{ key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' }, { key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' },
{ key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' }, { 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.ANALYSIS, icon: <AreaChartOutlined style={{ fontSize: '18px' }} />, label: 'Analysis' },
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' }, { key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' }, { key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
]; ];

View File

@@ -1,78 +1,430 @@
// src/apiClient.ts import axios from 'axios';
import axios from "axios";
import {
AnomalyAnalysisResponse,
ConversationAnalysisResponse,
ConversationFlowDetailResponse,
DiscoveryAnalysisResponse,
HostIntelligenceAnalysisResponse,
InterfaceHostAnalysisResponse,
InterfaceHostProtocolAnalysisResponse,
InterfaceProtocolPathAnalysisResponse,
} from '../types/analysis';
import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
import { import {
BridgeCreateRequest, BridgeCreateRequest,
BridgeLinkStateEnableRequest,
BridgeLinkStateWatcherStatus,
BridgeInfo, BridgeInfo,
BridgeRemoveRequest, BridgeRemoveRequest,
FullState, FullState,
InterfaceResetDefaultsRequest,
InterfaceResetDefaultsResponse,
InterfaceInfo, InterfaceInfo,
RouteInfo, RouteInfo,
} from "../types/network"; } from '../types/network';
import { SnifferStatusResponse } from "../types/sniffer"; import {
DeleteResult,
EnableRequest,
OperationResult,
RequirementsDeleteResult,
RequirementsUploadResult,
ScriptInfo,
ScriptUploadResponse,
ScriptWithStatus,
StatusForNameResponse,
} from '../types/scripting';
import { FetchPacketsResponse } from '../types/packets';
import {
SnifferStartRequest,
SnifferStartResponse,
SnifferStatusResponse,
SnifferStopRequest,
SnifferStopResponse,
} from '../types/sniffer';
const BASE = "http://mitm.lan/api"; const BASE = 'http://mitm.lan/api';
export const api = axios.create({ export const api = axios.create({
baseURL: BASE, baseURL: BASE,
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
timeout: 10000, timeout: 20000,
}); });
// Normalize FastAPI errors here
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
// FastAPI HTTPException format const detail = error?.response?.data?.detail ?? error?.response?.data?.message ?? error.message ?? 'Unknown error';
const detail =
error?.response?.data?.detail ??
error?.response?.data?.message ??
error.message ??
"Unknown error";
// Always reject with a standard Error
return Promise.reject(new Error(detail)); return Promise.reject(new Error(detail));
} },
); );
// Queries export const fetchHello = async (): Promise<any> => {
const res = await api.get('/hello');
return res.data;
};
export const fetchVersions = async (): Promise<any> => {
const res = await api.get('/versions');
return res.data;
};
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => { 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; return res.data;
}; };
export const fetchLinks = async (): Promise<InterfaceInfo[]> => { 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; return res.data;
}; };
export const fetchRoutes = async (): Promise<RouteInfo[]> => { 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; return res.data;
}; };
export const fetchBridges = async (): Promise<BridgeInfo[]> => { 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; return res.data;
}; };
export const fetchFullState = async (): Promise<FullState> => { 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; return res.data;
}; };
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => { export const resetInterfaceDefaults = async (
const res = await api.get<SnifferStatusResponse>("/sniffer/status"); req: InterfaceResetDefaultsRequest,
): Promise<InterfaceResetDefaultsResponse> => {
const res = await api.post<InterfaceResetDefaultsResponse>('/network/interfaces/reset-defaults', req);
return res.data; 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();
};
// Mutations
export const createBridge = async (req: BridgeCreateRequest) => { 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; return res.data;
}; };
export const removeBridge = async (req: BridgeRemoveRequest) => { 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; 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;
};
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');
return res.data;
};
export const fetchPackets = async (limit = 100): Promise<FetchPacketsResponse> => {
const res = await api.get<FetchPacketsResponse>('/packets/packets', { params: { limit } });
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();
};
export const clearPackets = async (): Promise<any> => {
const res = await api.delete('/packets/packets');
return res.data;
};
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;
};
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 },
});
return res.data;
};
export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult> => {
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 });
return res.data;
};
export const fetchScriptsAll = async (): Promise<ScriptWithStatus[]> => {
const res = await api.get<ScriptWithStatus[]>('/scripts/scripts');
return res.data;
};
export const listScripts = async (): Promise<ScriptInfo[]> => {
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<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);
}
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<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 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' && 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<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<OperationResult> => {
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
params: { qnum },
});
return res.data;
};
export default api;

View File

@@ -1,37 +1,36 @@
// src/AppRouter.tsx
import { Navigate, Route, Routes } from 'react-router-dom'; 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 Home from './pages/Home';
import Network from './pages/Network'; import Network from './pages/Network';
import Scripting from './pages/Scripting';
import Sniffing from './pages/Sniffing'; import Sniffing from './pages/Sniffing';
import { PATHS } from './routes'; 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() { export default function AppRouter() {
return ( return (
<Routes> <Routes>
{/* App is the top-level layout; Outlet renders the active child route */}
<Route path={PATHS.ROOT} element={<App />}> <Route path={PATHS.ROOT} element={<App />}>
{/* When the user hits '/', redirect to '/home' */}
<Route index element={<Navigate to={PATHS.HOME} replace />} /> <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.HOME.slice(1)} element={<Home />} />
<Route path={PATHS.NETWORK.slice(1)} element={<Network />} /> <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.SNIFFING.slice(1)} element={<Sniffing />} />
<Route path={PATHS.SCRIPTING.slice(1)} element={<Scripting />} />
{/* Fallback (renders inside layout too) */} <Route path={PATHS.FIREWALL.slice(1)} element={<Firewall />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Route> </Route>
</Routes> </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>
);
}

View File

@@ -0,0 +1,416 @@
import { CopyOutlined } from '@ant-design/icons';
import {
Alert,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Spin,
Typography,
notification,
} from 'antd';
import { ReactElement, useEffect, useState } from 'react';
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
import type { CmdResult, ExecResult } from '../types/firewall';
const { Paragraph, Text } = Typography;
const { Option } = Select;
type TableProp = {
family: string;
name: string;
};
type Props = {
open: boolean;
/**
* 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;
};
const FAMILY_DESCRIPTIONS: Record<string, string> = {
ip: 'IPv4 packets.',
ip6: 'IPv6 packets.',
inet: 'Both IPv4 and IPv6.',
arp: 'ARP packets.',
bridge: 'Packets passing through a bridge.',
netdev: 'Direct filtering on a network device (ingress).',
};
export default function FirewallAddChainModal({
open,
onClose,
table,
startOnPreview = false,
onSuccess,
}: Props): ReactElement {
const [form] = Form.useForm();
const [loadingRuleset, setLoadingRuleset] = useState(false);
const [rulesetEmpty, setRulesetEmpty] = useState<boolean>(false);
const [step, setStep] = useState<number>(startOnPreview ? 1 : 0);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<CmdResult[]>([]);
const [selectedFamily, setSelectedFamily] = useState<string>(table?.family ?? 'bridge');
const isPrefilled = Boolean(table?.family && table?.name);
useEffect(() => {
form.setFieldsValue({
family: table?.family ?? 'bridge',
tableName: table?.name ?? 'filter',
type: 'filter',
hook: 'forward',
priority: 0,
policy: 'accept',
});
setSelectedFamily(table?.family ?? 'bridge');
if (open) {
refreshRuleset();
setResults([]);
setRunning(false);
setStep(startOnPreview ? 1 : 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, table]);
async function refreshRuleset() {
setLoadingRuleset(true);
try {
const res = await fetchRuleset();
const ruleset = (res as any).ruleset;
if (ruleset && Array.isArray(ruleset.tables)) {
setRulesetEmpty(ruleset.tables.length === 0);
} else {
setRulesetEmpty(false);
}
} catch (err: any) {
notification.warning({
message: 'Could not load ruleset',
description: err?.message ?? String(err),
duration: 6,
});
setRulesetEmpty(false);
} finally {
setLoadingRuleset(false);
}
}
function buildCommands(values?: any): string[] {
const vals = values ?? form.getFieldsValue();
const family = (vals.family ?? table?.family ?? 'bridge').trim();
const tableName = (vals.tableName ?? table?.name ?? 'filter').trim();
const type = (vals.type ?? 'filter').trim();
const hook = (vals.hook ?? 'forward').trim();
const priority =
typeof vals.priority === 'number'
? vals.priority
: Number.isFinite(Number(vals.priority))
? Number(vals.priority)
: 0;
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];
}
async function executeCommands(cmds: string[]) {
setRunning(true);
setResults([]);
const acc: CmdResult[] = [];
for (const cmd of cmds) {
try {
const out = (await execFirewallRaw(cmd)) as ExecResult;
const success = out && (out.rc === 0 || out.rc === -1);
if (success) acc.push({ cmd, out });
else acc.push({ cmd, out, err: `rc: ${out.rc} stderr: ${out.stderr ?? ''}` });
} catch (err: any) {
acc.push({ cmd, err: err?.message ?? String(err) });
}
}
setResults(acc);
setRunning(false);
try {
await refreshRuleset();
} catch {
}
const hadError = acc.some((r) => r.err);
if (!hadError) {
notification.success({
message: 'Chain created',
description: 'Chain created and ruleset refreshed locally in the modal.',
duration: 4,
});
onClose?.(true);
if (onSuccess) onSuccess();
} else {
notification.error({
message: 'Some commands returned errors',
description: 'See execution results below for details.',
duration: 6,
});
setStep(1);
}
}
function renderFooter() {
return (
<Space>
<Button
onClick={() => {
if (step === 0) onClose?.(false);
else setStep(0);
}}
>
{step === 0 ? 'Cancel' : 'Back'}
</Button>
{step === 0 ? (
<Space>
<Button
icon={<CopyOutlined />}
onClick={async () => {
const txt = buildCommands(form.getFieldsValue()).join('\n');
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 () => {
try {
const requiredFields = ['chainName'];
if (!isPrefilled) requiredFields.push('tableName', 'family');
await form.validateFields(requiredFields as any);
setStep(1);
} catch {
}
}}
>
Preview
</Button>
</Space>
) : (
<Button
type="primary"
loading={running}
onClick={() => executeCommands(buildCommands(form.getFieldsValue()))}
>
Execute
</Button>
)}
</Space>
);
}
if (loadingRuleset) {
return (
<Modal title="Add Chain" open={open} onCancel={() => onClose?.(false)} footer={null} width={800} destroyOnClose>
<div style={{ textAlign: 'center', padding: 28 }}>
<Spin />
</div>
</Modal>
);
}
return (
<Modal
title="Add Chain"
open={open}
onCancel={() => onClose?.(false)}
width={800}
footer={renderFooter()}
destroyOnClose
>
{step === 0 && (
<div>
{rulesetEmpty && (
<Alert
type="info"
message="No firewall tables found"
description="You will need to create a table first before adding a chain."
style={{ marginBottom: 12 }}
/>
)}
<Form
form={form}
layout="vertical"
onValuesChange={(changed) => {
if (changed.family) setSelectedFamily(changed.family);
}}
>
<Row gutter={12}>
<Col span={8}>
{isPrefilled ? (
<Form.Item label="Family">
<Text strong>{table!.family}</Text>
<Paragraph type="secondary" style={{ marginTop: 8 }}>
{FAMILY_DESCRIPTIONS[table!.family]}
</Paragraph>
</Form.Item>
) : (
<Form.Item name="family" label="Family" rules={[{ required: true }]}>
<Select defaultValue="bridge">
<Option value="ip">ip</Option>
<Option value="ip6">ip6</Option>
<Option value="inet">inet</Option>
<Option value="arp">arp</Option>
<Option value="bridge">bridge</Option>
<Option value="netdev">netdev</Option>
</Select>
</Form.Item>
)}
</Col>
<Col span={8}>
{isPrefilled ? (
<Form.Item label="Table">
<Text strong>{table!.name}</Text>
</Form.Item>
) : (
<Form.Item name="tableName" label="Table Name" rules={[{ required: true }]}>
<Input placeholder="filter" />
</Form.Item>
)}
</Col>
</Row>
<Row gutter={12} style={{ marginTop: 8 }}>
<Col span={6}>
<Form.Item name="type" label="Type" initialValue="filter">
<Select>
<Option value="filter">filter</Option>
<Option value="nat">nat</Option>
<Option value="route">route</Option>
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="hook" label="Hook" initialValue="forward">
<Select>
<Option value="input">input</Option>
<Option value="forward">forward</Option>
<Option value="output">output</Option>
<Option value="ingress">ingress</Option>
<Option value="egress">egress</Option>
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="priority" label="Priority" initialValue={0}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="policy" label="Policy" initialValue="accept">
<Select>
<Option value="accept">accept</Option>
<Option value="drop">drop</Option>
<Option value="">(none)</Option>
</Select>
</Form.Item>
</Col>
</Row>
</Form>
</div>
)}
{step === 1 && (
<>
<Card title="Command preview" style={{ marginBottom: 12 }}>
<Paragraph>
The command below will be executed on the server (server will prefix with <Text code>nft</Text>):
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fafafa', padding: 12 }}>
{buildCommands(form.getFieldsValue()).join('\n')}
</pre>
<Space style={{ marginTop: 12 }}>
<Button
icon={<CopyOutlined />}
onClick={async () => {
const txt = buildCommands(form.getFieldsValue()).join('\n');
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>
</Space>
</Card>
{results.length > 0 && (
<Card title="Execution results" style={{ marginTop: 12 }}>
{results.map((r, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>{r.cmd}</Text>
{r.err ? (
<Paragraph type="danger">{r.err}</Paragraph>
) : (
<>
<Paragraph>
<Text type="secondary">rc:</Text> {r.out?.rc}
</Paragraph>
{r.out?.stdout ? (
<>
<Text type="secondary">stdout:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stdout}</pre>
</>
) : null}
{r.out?.stderr ? (
<>
<Text type="secondary">stderr:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stderr}</pre>
</>
) : null}
</>
)}
</div>
))}
</Card>
)}
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,276 @@
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';
const { Paragraph, Text } = Typography;
const { Option } = Select;
type ExecResult = {
rc: number;
stdout?: string | null;
stderr?: string | null;
};
type CmdResult = {
cmd: string;
out?: ExecResult;
err?: string;
};
type Props = {
open: boolean;
onClose?: (created?: boolean) => void; // created === true when a table was created
startOnPreview?: boolean;
};
const FAMILY_DESCRIPTIONS: Record<string, string> = {
ip: 'IPv4 packets.',
ip6: 'IPv6 packets.',
inet: 'Both IPv4 and IPv6.',
arp: 'ARP packets.',
bridge: 'Packets passing through a bridge.',
netdev: 'Direct filtering on a network device (ingress).',
};
export default function FirewallAddTableModal({ open, onClose, startOnPreview = false }: Props): ReactElement {
const [form] = Form.useForm();
const [localLoadingRuleset, setLocalLoadingRuleset] = useState(false);
const [rulesetEmpty, setRulesetEmpty] = useState<boolean>(false);
const [step, setStep] = useState<number>(startOnPreview ? 1 : 0);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<CmdResult[]>([]);
const [selectedFamily, setSelectedFamily] = useState<string>('bridge');
useEffect(() => {
form.setFieldsValue({
family: 'bridge',
tableName: 'filter',
});
setSelectedFamily('bridge');
if (open) {
refreshRuleset();
setResults([]);
setRunning(false);
setStep(startOnPreview ? 1 : 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function refreshRuleset() {
setLocalLoadingRuleset(true);
try {
const res = await fetchRuleset();
const ruleset = (res as any).ruleset;
if (ruleset && Array.isArray(ruleset.tables)) {
setRulesetEmpty(ruleset.tables.length === 0);
} else {
setRulesetEmpty(false);
}
} catch (err: any) {
notification.warning({
message: 'Could not load ruleset',
description: err?.message ?? String(err),
duration: 6,
});
setRulesetEmpty(false);
} finally {
setLocalLoadingRuleset(false);
}
}
function buildCommands(values: any): string[] {
const family = (values.family ?? 'bridge').trim();
const table = (values.tableName ?? 'filter').trim();
return [`add table ${family} ${table}`];
}
async function executeCommands(cmds: string[]) {
setRunning(true);
setResults([]);
const acc: CmdResult[] = [];
for (const cmd of cmds) {
try {
const out = (await execFirewallRaw(cmd)) as ExecResult;
const success = out && (out.rc === 0 || out.rc === -1);
if (success) acc.push({ cmd, out });
else acc.push({ cmd, out, err: `rc: ${out.rc} stderr: ${out.stderr ?? ''}` });
} catch (err: any) {
acc.push({ cmd, err: err?.message ?? String(err) });
}
}
setResults(acc);
setRunning(false);
try {
await refreshRuleset();
} catch {
}
const hadError = acc.some((r) => r.err);
if (!hadError) {
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 {
notification.error({
message: 'Some commands returned errors',
description: 'See execution results below for details.',
duration: 6,
});
setStep(1);
}
}
function renderFooter() {
return (
<Space>
<Button
onClick={() => {
if (step === 0) onClose?.(false);
else setStep(0);
}}
>
{step === 0 ? 'Cancel' : 'Back'}
</Button>
{step === 0 ? (
<Space>
<Button
type="primary"
onClick={() => {
form
.validateFields()
.then(() => setStep(1))
.catch(() =>
notification.warning({
message: 'Validation',
description: 'Please fill required fields before preview.',
}),
);
}}
>
Preview
</Button>
</Space>
) : (
<Button
type="primary"
loading={running}
onClick={() => executeCommands(buildCommands(form.getFieldsValue()))}
>
Execute
</Button>
)}
</Space>
);
}
if (localLoadingRuleset) {
return (
<Modal title="Create Table" open={open} onCancel={() => onClose?.(false)} footer={null} width={700}>
<div style={{ textAlign: 'center', padding: 28 }}>
<Spin />
</div>
</Modal>
);
}
return (
<Modal
title="Create Table"
open={open}
onCancel={() => onClose?.(false)}
width={700}
footer={renderFooter()}
destroyOnClose
>
{step === 0 && (
<div>
<Form
form={form}
layout="vertical"
onValuesChange={(changed) => {
if (changed.family) setSelectedFamily(changed.family);
}}
>
<Row gutter={12}>
<Col span={8}>
<Form.Item name="family" label="Family" rules={[{ required: true }]}>
<Select defaultValue="bridge" value={selectedFamily}>
<Option value="ip">ip</Option>
<Option value="ip6">ip6</Option>
<Option value="inet">inet</Option>
<Option value="arp">arp</Option>
<Option value="bridge">bridge</Option>
<Option value="netdev">netdev</Option>
</Select>
</Form.Item>
<Paragraph type="secondary" style={{ marginTop: 8 }}>
{FAMILY_DESCRIPTIONS[selectedFamily]}
</Paragraph>
</Col>
<Col span={8}>
<Form.Item name="tableName" label="Table Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
</Row>
</Form>
</div>
)}
{step === 1 && (
<>
<Card title="Command preview" style={{ marginBottom: 12 }}>
<Paragraph>
The command below will be executed on the server (server will prefix with <Text code>nft</Text>):
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fafafa', padding: 12 }}>
{buildCommands(form.getFieldsValue()).join('\n')}
</pre>
</Card>
{results.length > 0 && (
<Card title="Execution results" style={{ marginTop: 12 }}>
{results.map((r, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>{r.cmd}</Text>
{r.err ? (
<Paragraph type="danger">{r.err}</Paragraph>
) : (
<>
<Paragraph>
<Text type="secondary">rc:</Text> {r.out?.rc}
</Paragraph>
{r.out?.stdout ? (
<>
<Text type="secondary">stdout:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stdout}</pre>
</>
) : null}
{r.out?.stderr ? (
<>
<Text type="secondary">stderr:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stderr}</pre>
</>
) : null}
</>
)}
</div>
))}
</Card>
)}
</>
)}
</Modal>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,448 @@
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, 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;
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;
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;
}
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;
}
}
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 (rule?.expr && typeof rule.expr === 'object') {
try {
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
} catch {
}
}
try {
return JSON.stringify(rule, null, 2);
} catch {
return String(rule);
}
}
/* Helper: success RC */
function isSuccessRc(out?: ExecResult | null): boolean {
if (!out) return false;
return out.rc === 0 || out.rc === -1;
}
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);
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' });
} catch (err: any) {
acc.push({ cmd, err: err?.message ?? String(err) });
}
}
return acc;
}, []);
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>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
onOk: async () => {
try {
const res = await runCommands([cmd]);
const first = res[0];
if (!first.err) {
notification.success({ message: 'Rule deleted', description: cmd });
} else {
notification.error({ message: 'Delete returned error', description: first.err });
}
} catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
} finally {
try {
await refresh();
} catch {
}
}
},
});
},
[runCommands, refresh],
);
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: (
<>
<Paragraph>
This will delete the chain <i>{chain}</i> in table <i>{table}</i> unrevertably.
</Paragraph>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
onOk: async () => {
try {
const res = await runCommands([cmd]);
const first = res[0];
if (!first.err) {
notification.success({ message: `Chain ${chain} deleted`, description: cmd });
} else {
notification.error({ message: 'Chain deletion returned error', description: first.err });
}
} catch (err: any) {
notification.error({ message: 'Chain deletion failed', description: err?.message ?? String(err) });
} finally {
try {
await refresh();
} catch {
}
}
},
});
},
[runCommands, refresh],
);
const handleDeleteTable = useCallback(
async (family: string | null | undefined, table: string) => {
const cmd = `delete table ${family ?? 'inet'} ${table}`;
Modal.confirm({
title: 'Delete Table',
content: (
<>
<Paragraph>
This will delete the table <i>{table}</i> including all its chains and rules unrevertably.
</Paragraph>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
onOk: async () => {
try {
const res = await runCommands([cmd]);
const first = res[0];
if (!first.err) {
notification.success({ message: `Table ${table} deleted`, description: cmd });
} else {
notification.error({ message: 'Table deletion returned error', description: first.err });
}
} catch (err: any) {
notification.error({ message: 'Table deletion failed', description: err?.message ?? String(err) });
} finally {
try {
await refresh();
} catch {
}
}
},
});
},
[runCommands, refresh],
);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await refresh();
notification.success({ message: 'Ruleset refreshed' });
} catch (err: any) {
notification.error({ message: 'Refresh failed', description: err?.message ?? String(err) });
} finally {
setRefreshing(false);
}
}, [refresh]);
const tablesToRender = useMemo(() => tables ?? [], [tables]);
if (error)
return <Alert type="error" message="Failed to load firewall rules" description={error.message ?? String(error)} />;
return (
<>
<FirewallAddTableModal
open={isOpenTableCreatorModal}
onClose={(created?: boolean) => {
setIsOpenTableCreatorModal(false);
if (created) {
void refresh().catch(() => {});
}
}}
/>
<Card
title="Firewall Tables"
extra={
<Space>
<Button
onClick={handleRefresh}
loading={refreshing}
icon={refreshing ? <Spin size="small" /> : <ReloadOutlined />}
>
Refresh Ruleset
</Button>
<Button type="primary" onClick={() => setIsOpenTableCreatorModal(true)}>
Add Table
</Button>
</Space>
}
>
{tablesToRender.length === 0 && (
<Alert
type="info"
message="No firewall tables found"
description="You can create a new table using the button above."
style={{ marginBottom: 12 }}
/>
)}
{tablesToRender.map((table) => {
const totalRules = table.chains.reduce((acc, c) => acc + (c.rules?.length ?? 0), 0);
return (
<Card
key={`${table.family ?? 'any'}:${table.name}`}
type="inner"
style={{ marginBottom: 16 }}
title={
<div style={{ display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Title level={5} style={{ margin: 0 }}>
Table {table.name}
</Title>
<Text type="secondary">
<b>Family:</b> {table.family ?? 'unknown'} &nbsp; <b>Chains:</b> {table.chains.length} &nbsp;{' '}
<b>Total Rules:</b> {totalRules}
</Text>
</div>
<Space>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteTable(table.family, table.name)}
/>
<Button type="primary" onClick={() => setIsOpenChainCreatorModal(true)}>
Add Chain
</Button>
</Space>
</div>
}
>
<FirewallAddChainModal
open={isOpenChainCreatorModal}
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> = [
{ title: 'Rule #', dataIndex: 'idx', width: 80 },
{
title: 'Handle',
dataIndex: 'handle',
width: 80,
render: (v) => v ?? '-',
},
{
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 ? (
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
/>
) : (
<Space>
<Button size="small" disabled icon={<ArrowDownOutlined />} />
</Space>
),
},
];
const dataSource = (chain.rules ?? []).map((r: RuleOut, idx: number) => ({
key: `${chain.name}:${idx}`,
idx: idx + 1,
handle: r.handle ?? null,
frontendParsed: renderRuleFriendly(r),
backendtext: r.text,
}));
return (
<Card
key={`${table.name}:${chain.name}`}
type="inner"
style={{ marginTop: 12 }}
title={
<div
style={{
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<Text strong>{chain.name}</Text>
<Text type="secondary" style={{ marginLeft: 12 }}>
<b>Hook:</b> {chain.hook ?? '-'} &nbsp; &nbsp; <b>Type:</b> {chain.type ?? '-'} &nbsp;{' '}
<b>Policy:</b> {chain.policy ?? '-'} &nbsp; <b>Priority:</b> {chain.priority ?? '-'}
</Text>
</div>
<Space>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
/>
</Space>
</div>
}
>
<Table
style={{ marginTop: 12 }}
size="small"
columns={columns}
dataSource={dataSource}
pagination={false}
/>
</Card>
);
})}
</Space>
</Card>
);
})}
</Card>
</>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
// src/components/PythonEditor.tsx
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/themes/prism.css';
import React, { useEffect, useMemo, useRef } from 'react';
export type PythonEditorProps = {
value: string;
onChange: (code: string) => void;
readOnly?: boolean;
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,
wrap = false,
fontSize = 14,
tabSize = 4,
}: PythonEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const preRef = useRef<HTMLPreElement | null>(null);
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: DEFAULT_FONT,
fontSize: `${fontSize}px`,
lineHeight: `${lineHeight}px`,
tabSize,
whiteSpace: wrap ? 'pre-wrap' : 'pre',
wordBreak: 'break-word',
padding: 12,
boxSizing: 'border-box',
}),
[fontSize, lineHeight, tabSize, wrap],
);
return (
<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}
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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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} />;
}

View 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: [],
};
}

View File

@@ -1,7 +1,7 @@
// src/hooks/useNetwork.ts import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from 'react';
import { useCallback, useState } from "react";
import * as api from "../api/apiClient"; import * as api from '../api/apiClient';
import type { import type {
BridgeCreateRequest, BridgeCreateRequest,
BridgeInfo, BridgeInfo,
@@ -9,24 +9,15 @@ import type {
FullState, FullState,
InterfaceInfo, InterfaceInfo,
RouteInfo, RouteInfo,
} from "../types/network"; } from '../types/network';
import { SnifferStatusResponse } from "../types/sniffer"; 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() { export function useBackendAPI() {
const qc = useQueryClient(); const queryClient = useQueryClient();
// per-query enabled flags (start false => no automatic fetch)
const [interfacesEnabled, setInterfacesEnabled] = useState(false); const [interfacesEnabled, setInterfacesEnabled] = useState(false);
const [linksEnabled, setLinksEnabled] = useState(false); const [linksEnabled, setLinksEnabled] = useState(false);
const [routesEnabled, setRoutesEnabled] = useState(false); const [routesEnabled, setRoutesEnabled] = useState(false);
@@ -34,132 +25,126 @@ export function useBackendAPI() {
const [fullStateEnabled, setFullStateEnabled] = useState(false); const [fullStateEnabled, setFullStateEnabled] = useState(false);
const [snifferStatusEnabled, setSnifferStatusEnabled] = useState(false); const [snifferStatusEnabled, setSnifferStatusEnabled] = useState(false);
// common query options once enabled
const commonOptions = { const commonOptions = {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
staleTime: 1000 * 10, // 10s staleTime: TEN_SECONDS,
}; };
// Queries (disabled initially)
const interfacesQuery = useQuery<InterfaceInfo[]>({ const interfacesQuery = useQuery<InterfaceInfo[]>({
queryKey: ["interfaces"], queryKey: ['interfaces'],
queryFn: api.fetchInterfaces, queryFn: api.fetchInterfaces,
enabled: interfacesEnabled, enabled: interfacesEnabled,
...commonOptions, ...commonOptions,
}); });
const linksQuery = useQuery<InterfaceInfo[]>({ const linksQuery = useQuery<InterfaceInfo[]>({
queryKey: ["links"], queryKey: ['links'],
queryFn: api.fetchLinks, queryFn: api.fetchLinks,
enabled: linksEnabled, enabled: linksEnabled,
...commonOptions, ...commonOptions,
}); });
const routesQuery = useQuery<RouteInfo[]>({ const routesQuery = useQuery<RouteInfo[]>({
queryKey: ["routes"], queryKey: ['routes'],
queryFn: api.fetchRoutes, queryFn: api.fetchRoutes,
enabled: routesEnabled, enabled: routesEnabled,
...commonOptions, ...commonOptions,
}); });
const bridgesQuery = useQuery<BridgeInfo[]>({ const bridgesQuery = useQuery<BridgeInfo[]>({
queryKey: ["bridges"], queryKey: ['bridges'],
queryFn: api.fetchBridges, queryFn: api.fetchBridges,
enabled: bridgesEnabled, enabled: bridgesEnabled,
...commonOptions, ...commonOptions,
}); });
const fullStateQuery = useQuery<FullState>({ const fullStateQuery = useQuery<FullState>({
queryKey: ["full-state"], queryKey: ['full-state'],
queryFn: api.fetchFullState, queryFn: api.fetchFullState,
enabled: fullStateEnabled, enabled: fullStateEnabled,
...commonOptions, ...commonOptions,
}); });
const snifferStatusQuery = useQuery<SnifferStatusResponse>({ const snifferStatusQuery = useQuery<SnifferStatusResponse>({
queryKey: ["sniffer-status"], queryKey: ['sniffer-status'],
queryFn: api.fetchSnifferStatus, queryFn: api.fetchSnifferStatus,
enabled: fullStateEnabled, enabled: snifferStatusEnabled,
...commonOptions, ...commonOptions,
}); });
// Imperative fetch helpers that also enable auto-refetch behavior
const fetchInterfaces = useCallback(async () => { const fetchInterfaces = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({ const result = await queryClient.fetchQuery<InterfaceInfo[]>({
queryKey: ["interfaces"], queryKey: ['interfaces'],
queryFn: api.fetchInterfaces, queryFn: api.fetchInterfaces,
staleTime: 1000 * 10 staleTime: TEN_SECONDS,
}); });
setInterfacesEnabled(true); setInterfacesEnabled(true);
return res; return result;
}, [qc]); }, [queryClient]);
const fetchLinks = useCallback(async () => { const fetchLinks = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({ const result = await queryClient.fetchQuery<InterfaceInfo[]>({
queryKey: ["links"], queryKey: ['links'],
queryFn: api.fetchLinks, queryFn: api.fetchLinks,
staleTime: 1000 * 10 staleTime: TEN_SECONDS,
}); });
setLinksEnabled(true); setLinksEnabled(true);
return res; return result;
}, [qc]); }, [queryClient]);
const fetchRoutes = useCallback(async () => { const fetchRoutes = useCallback(async () => {
const res = await qc.fetchQuery<RouteInfo[]>({ const result = await queryClient.fetchQuery<RouteInfo[]>({
queryKey: ["routes"], queryKey: ['routes'],
queryFn: api.fetchRoutes, queryFn: api.fetchRoutes,
staleTime: 1000 * 10 staleTime: TEN_SECONDS,
}); });
setRoutesEnabled(true); setRoutesEnabled(true);
return res; return result;
}, [qc]); }, [queryClient]);
const fetchBridges = useCallback(async () => { const fetchBridges = useCallback(async () => {
const res = await qc.fetchQuery<BridgeInfo[]>({ const result = await queryClient.fetchQuery<BridgeInfo[]>({
queryKey: ["bridges"], queryKey: ['bridges'],
queryFn: api.fetchBridges, queryFn: api.fetchBridges,
staleTime: 1000 * 10 staleTime: TEN_SECONDS,
}); });
setBridgesEnabled(true); setBridgesEnabled(true);
return res; return result;
}, [qc]); }, [queryClient]);
const fetchFullState = useCallback(async () => { const fetchFullState = useCallback(async () => {
const res = await qc.fetchQuery<FullState>({ const result = await queryClient.fetchQuery<FullState>({
queryKey: ["full-state"], queryKey: ['full-state'],
queryFn: api.fetchFullState, queryFn: api.fetchFullState,
staleTime: 1000 * 5 staleTime: FIVE_SECONDS,
}); });
setFullStateEnabled(true); setFullStateEnabled(true);
return res; return result;
}, [qc]); }, [queryClient]);
const fetchSnifferStatus = useCallback(async () => { const fetchSnifferStatus = useCallback(async () => {
const res = await qc.fetchQuery<SnifferStatusResponse>({ const result = await queryClient.fetchQuery<SnifferStatusResponse>({
queryKey: ["sniffer-status"], queryKey: ['sniffer-status'],
queryFn: api.fetchSnifferStatus, queryFn: api.fetchSnifferStatus,
staleTime: 1000 * 5 staleTime: FIVE_SECONDS,
}); });
setFullStateEnabled(true); setSnifferStatusEnabled(true);
return res; return result;
}, [qc]); }, [queryClient]);
// Local loading state for simple UI feedback
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isRemoving, setIsRemoving] = useState(false); const [isRemoving, setIsRemoving] = useState(false);
// Simple imperative functions that call the API and invalidate queries
async function createBridge(payload: BridgeCreateRequest) { async function createBridge(payload: BridgeCreateRequest) {
setIsCreating(true); setIsCreating(true);
try { try {
await api.createBridge(payload); await api.createBridge(payload);
// If the query is enabled it will refetch automatically after invalidation. await queryClient.invalidateQueries({ queryKey: ['bridges'] });
await qc.invalidateQueries({ queryKey: ["bridges"] }); await queryClient.invalidateQueries({ queryKey: ['full-state'] });
await qc.invalidateQueries({ queryKey: ["full-state"] }); await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
await qc.invalidateQueries({ queryKey: ["interfaces"] }); } catch (error) {
} catch (err) {
const message = 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); throw new Error(message);
} finally { } finally {
setIsCreating(false); setIsCreating(false);
@@ -170,45 +155,43 @@ export function useBackendAPI() {
setIsRemoving(true); setIsRemoving(true);
try { try {
await api.removeBridge(payload); await api.removeBridge(payload);
await qc.invalidateQueries({ queryKey: ["bridges"] }); await queryClient.invalidateQueries({ queryKey: ['bridges'] });
await qc.invalidateQueries({ queryKey: ["full-state"] }); await queryClient.invalidateQueries({ queryKey: ['full-state'] });
await qc.invalidateQueries({ queryKey: ["interfaces"] }); await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
await qc.invalidateQueries({ queryKey: ["sniffer-status"] }); await queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
} catch (err) { } catch (error) {
const message = 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); throw new Error(message);
} finally { } finally {
setIsRemoving(false); setIsRemoving(false);
} }
} }
// Convenience: invalidate helpers
function refreshInterfaces() { function refreshInterfaces() {
return qc.invalidateQueries({ queryKey: ["interfaces"] }); return queryClient.invalidateQueries({ queryKey: ['interfaces'] });
} }
function refreshLinks() { function refreshLinks() {
return qc.invalidateQueries({ queryKey: ["links"] }); return queryClient.invalidateQueries({ queryKey: ['links'] });
} }
function refreshRoutes() { function refreshRoutes() {
return qc.invalidateQueries({ queryKey: ["routes"] }); return queryClient.invalidateQueries({ queryKey: ['routes'] });
} }
function refreshBridges() { function refreshBridges() {
return qc.invalidateQueries({ queryKey: ["bridges"] }); return queryClient.invalidateQueries({ queryKey: ['bridges'] });
} }
function refreshFullState() { function refreshFullState() {
return qc.invalidateQueries({ queryKey: ["full-state"] }); return queryClient.invalidateQueries({ queryKey: ['full-state'] });
} }
function refreshSnifferStatus() { function refreshSnifferStatus() {
return qc.invalidateQueries({ queryKey: ["sniffer-status"] }); return queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
} }
// Convenience: refresh all queries
function refreshAll() { function refreshAll() {
refreshInterfaces(); refreshInterfaces();
refreshLinks(); refreshLinks();
@@ -219,39 +202,28 @@ export function useBackendAPI() {
} }
return { return {
// queries
interfacesQuery, interfacesQuery,
linksQuery, linksQuery,
routesQuery, routesQuery,
bridgesQuery, bridgesQuery,
fullStateQuery, fullStateQuery,
snifferStatusQuery, snifferStatusQuery,
// manual fetchers (fetch+enable auto-updates)
fetchInterfaces, fetchInterfaces,
fetchLinks, fetchLinks,
fetchRoutes, fetchRoutes,
fetchBridges, fetchBridges,
fetchFullState, fetchFullState,
fetchSnifferStatus, fetchSnifferStatus,
// simple mutation functions (imperative)
createBridge, createBridge,
removeBridge, removeBridge,
// local loading flags
isCreating, isCreating,
isRemoving, isRemoving,
// invalidate helpers
refreshInterfaces, refreshInterfaces,
refreshLinks, refreshLinks,
refreshRoutes, refreshRoutes,
refreshBridges, refreshBridges,
refreshFullState, refreshFullState,
refreshSnifferStatus, refreshSnifferStatus,
// refresh all
refreshAll, refreshAll,
}; };
} }

View 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;

View File

@@ -0,0 +1,57 @@
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 TerminalIcon = forwardRef<SVGSVGElement, IconProps>(
({ size = 24, color = 'currentColor', title, ...rest }, ref) => {
// If user passed a numeric size, treat as px
const sizeValue = typeof size === 'number' ? `${size}px` : size;
return (
<svg
ref={ref}
width={sizeValue}
height={sizeValue}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={title ? undefined : true}
role={title ? 'img' : 'presentation'}
{...rest}
>
{title ? <title>{title}</title> : null}
<path
d="M5.0333 14.8284L6.44751 16.2426L10.6902 12L6.44751 7.75733L5.0333 9.17155L7.86172 12L5.0333 14.8284Z"
fill={color}
/>
<path d="M15 14H11V16H15V14Z" fill={color} />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 2C0.895431 2 0 2.89543 0 4V20C0 21.1046 0.89543 22 2 22H22C23.1046 22 24 21.1046 24 20V4C24 2.89543 23.1046 2 22 2H2ZM22 4H2L2 20H22V4Z"
fill={color}
/>
</svg>
);
},
);
TerminalIcon.displayName = 'TerminalIcon';
export default TerminalIcon;

View File

@@ -14,7 +14,7 @@ html {
/* Root background and typography (matches theme background + font) */ /* Root background and typography (matches theme background + font) */
body { 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 */ background-color: #f9f9f9; /* matches theme.palette.background.default */
color: #262626; /* matches theme.palette.text.primary */ color: #262626; /* matches theme.palette.text.primary */
line-height: 1.6; line-height: 1.6;
@@ -70,17 +70,15 @@ a:hover {
.fade-enter-active { .fade-enter-active {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
transition: opacity 0.3s, transform 0.3s; transition:
opacity 0.3s,
transform 0.3s;
} }
/* === Code blocks / preformatted text === */ /* === Code blocks / preformatted text === */
pre, pre,
code { code {
background-color: #f0f0f0; font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: #262626;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: "JetBrains Mono", "Fira Code", monospace;
} }
/* === App container fix for fixed header === */ /* === App container fix for fixed header === */

View 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>
);
}

View File

@@ -0,0 +1,62 @@
// 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">
<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>
);
};
export default Firewall;

Some files were not shown because too many files have changed in this diff Show More