Repository navigation
Expand file tree
/
Copy pathnetdeflect.py
More file actions
7643 lines (6888 loc) · 338 KB
/
Copy pathnetdeflect.py
File metadata and controls
7643 lines (6888 loc) · 338 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
NetDeflect v4 - DDoS detection and mitigation for Linux servers.
https://github.com/0vm/NetDeflect
NetDeflect watches the traffic arriving at your server. When it crosses your
thresholds it inspects a sample of the packets, works out which attack is
running, and installs firewall rules in the kernel that drop the attack while
your real traffic keeps flowing. The kernel does the dropping, so NetDeflect
itself stays light even on a one-core VPS during a large attack.
How it decides what to do
-------------------------
* Faked sources are never blocked. Most floods use forged source addresses,
so blocking those addresses would do nothing useful and could block an
innocent network. They are stopped by dropping the attack pattern and by
per-address rate limits instead.
* Real attackers are blocked. Reflection/amplification servers and botnet
machines really did send the traffic, so their addresses are blocked for a
while (never your gateway, your resolvers, your SSH session, your own
addresses or anything you whitelist).
* Your game and VPN ports stay usable. Real players and VPN users are learned
while things are quiet and let through first during an attack.
* The first line of defence keeps no per-connection state, so a flood of
fake addresses cannot exhaust it. Connection tracking is optional.
Run with --help for options, or --setup for a guided setup.
"""
import os
import re
import sys
import json
import stat
import time
import struct
import hashlib
import argparse
import logging
import logging.handlers
import signal
import shutil
import socket
import subprocess
import ipaddress
import threading
import configparser
from collections import Counter, defaultdict
from datetime import datetime
VERSION = "4.0.0"
VERSION_STRING = f"NetDeflect v{VERSION}"
# Resolve paths relative to this script so the tool works under systemd / cron
# regardless of the working directory.
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "application_data")
CAPTURE_DIR = os.path.join(DATA_DIR, "captures")
IP_DIR = os.path.join(DATA_DIR, "ips")
ANALYSIS_DIR = os.path.join(DATA_DIR, "attack_analysis")
AUTODETECT_FILE = os.path.join(DATA_DIR, "new_detected_methods.json")
STATE_FILE = os.path.join(DATA_DIR, "state.json")
METRICS_FILE = os.path.join(DATA_DIR, "netdeflect.prom")
SETTINGS_FILE = os.path.join(BASE_DIR, "settings.ini")
METHODS_FILE = os.path.join(BASE_DIR, "methods.json")
TEMPLATE_FILE = os.path.join(BASE_DIR, "notification_template.json")
log = logging.getLogger("netdeflect")
# --------------------------------------------------------------------------- #
# Protocol helpers
# --------------------------------------------------------------------------- #
PROTO_NAMES = {
"icmp": 1, "igmp": 2, "tcp": 6, "udp": 17, "gre": 47,
"esp": 50, "ah": 51, "icmp6": 58, "icmpv6": 58, "ospf": 89,
"sctp": 132, "ipip": 4, "ipv6": 41, "l2tp": 115,
}
PROTO_NUM_TO_NAME = {1: "icmp", 4: "ipip", 6: "tcp", 17: "udp", 41: "ipv6", 47: "gre",
50: "esp", 51: "ah", 58: "icmpv6", 115: "l2tp", 132: "sctp"}
# TCP flag bit values (low byte of the flags field).
TCP_FLAG_BITS = {
"FIN": 0x01, "SYN": 0x02, "RST": 0x04, "PSH": 0x08,
"ACK": 0x10, "URG": 0x20, "ECE": 0x40, "CWR": 0x80,
}
TCP_CORE_MASK = 0x3F # FIN|SYN|RST|PSH|ACK|URG
def proto_to_num(value):
"""Accept 'udp', 'tcp', 6, '6', etc. and return the IP protocol number."""
if value is None:
return None
if isinstance(value, int):
return value
v = str(value).strip().lower()
if v in PROTO_NAMES:
return PROTO_NAMES[v]
if v.isdigit():
return int(v)
raise ValueError(f"Unknown protocol: {value!r}")
def flags_to_value(spec):
"""'SYN,ACK' -> 0x12 ; 'none'/'null' -> 0 ; None -> None."""
if spec is None:
return None
s = str(spec).strip().lower()
if s in ("none", "null", "0", ""):
return 0
value = 0
for part in str(spec).split(","):
name = part.strip().upper()
if name not in TCP_FLAG_BITS:
raise ValueError(f"Unknown TCP flag: {part!r}")
value |= TCP_FLAG_BITS[name]
return value
def default_flag_mask(flags_value):
"""If a signature names ECE/CWR we must compare the full 8 bits, otherwise a
flag set that includes ECE/CWR could never match (the classic v2/v3 bug where
SYN-ECN-CWR was dead). Core-6 signatures keep the 0x3F mask so ECN negotiation
on an ordinary SYN does not spoil a plain 'SYN' match."""
if flags_value is None:
return None
return 0xFF if (flags_value & 0xC0) else TCP_CORE_MASK
def parse_version(tag):
"""'v2.10.1' -> (2, 10, 1) so comparisons are numeric, not lexical."""
nums = []
for part in str(tag).lower().lstrip("v").split("."):
digits = "".join(ch for ch in part if ch.isdigit())
nums.append(int(digits) if digits else 0)
return tuple(nums) if nums else (0,)
def _is_hex(s):
try:
int(s, 16)
return len(s) % 2 == 0
except (ValueError, TypeError):
return False
# --------------------------------------------------------------------------- #
# Category policy - the heart of the spoof-safe design
# --------------------------------------------------------------------------- #
# spoofable : True -> source address is untrustworthy; NEVER block by IP.
# strategy : "filter" (pattern drop), "syn" (per-source SYN rate limit),
# "ratelimit" (per-source rate limit), "block_src" (block real
# source IPs), "conn_limit" (per-source concurrent connection cap,
# needs conntrack), "report" (log/alert only).
# real_source : True when the source IP is genuinely the host sending the
# packets (a real reflector or a real attacker) so it CAN be
# blocked. False when forged.
# needs_ct : rule can only be enforced in the post-conntrack chain.
CATEGORY_POLICY = {
# Reflection/amplification RESPONSES (keyed on a service source port): the
# source is a real reflector. Pattern-drop always; optionally null-route the
# heavy reflectors to drain the attacker pool.
"reflection": {"spoofable": True, "strategy": "filter", "real_source": True},
"amplification": {"spoofable": True, "strategy": "filter", "real_source": True},
# TCP reflection (unsolicited SYN-ACK/ACK/RST-ACK from a service source port).
# The "reflectors" are legitimate web/CDN/BGP servers, so NEVER block them;
# and only an UNSOLICITED reply is an attack, so this needs conntrack (the
# established/related accept runs first) and drops only state NEW/invalid.
"reflection_tcp": {"spoofable": True, "strategy": "filter", "real_source": False, "needs_ct": True},
# Reflection/amplification TRIGGER queries arriving AT us (keyed on a service
# dport, e.g. DNS ANY to udp/53). The source is the spoofed victim or a forged
# address - never block it; rate-limit the request shape instead.
"amplifier_abuse": {"spoofable": True, "strategy": "ratelimit", "real_source": False},
"dns_query": {"spoofable": True, "strategy": "ratelimit", "real_source": False},
# Spoofed-source floods: forged addresses, never IP-blocked.
"udp_flood": {"spoofable": True, "strategy": "filter", "real_source": False},
"tcp_flood": {"spoofable": True, "strategy": "filter", "real_source": False},
"tcp_flag": {"spoofable": True, "strategy": "filter", "real_source": False},
"stateful_flood": {"spoofable": True, "strategy": "report", "real_source": False, "needs_ct": True},
"ack_flood": {"spoofable": True, "strategy": "report", "real_source": False, "needs_ct": True},
"syn_flood": {"spoofable": True, "strategy": "syn", "real_source": False},
"icmp_flood": {"spoofable": True, "strategy": "ratelimit", "real_source": False},
"proto_flood": {"spoofable": True, "strategy": "filter", "real_source": False},
"fragment_flood": {"spoofable": True, "strategy": "filter", "real_source": False},
# Connectionless UDP game queries: forged just as easily as any UDP, so
# rate-limit the shape; do NOT block the (spoofable) source.
"game_query": {"spoofable": True, "strategy": "ratelimit", "real_source": False},
# Direct application-layer attacks where the source is the real attacker.
"botnet": {"spoofable": False, "strategy": "block_src", "real_source": True},
"scanner": {"spoofable": False, "strategy": "block_src", "real_source": True},
"conn_flood": {"spoofable": False, "strategy": "conn_limit", "real_source": True, "needs_ct": True},
"unclassified": {"spoofable": True, "strategy": "report", "real_source": False},
}
def policy_for(category):
return CATEGORY_POLICY.get(category, CATEGORY_POLICY["unclassified"])
# --------------------------------------------------------------------------- #
# Signatures
# --------------------------------------------------------------------------- #
# A packet dict (produced by the decoder or a test fixture) has keys:
# len (L3 IP datagram length), frame_len, proto, src, dst, sport, dport,
# flags (TCP), icmp_type, icmp_code, ttl, tos (DSCP byte), payload (bytes of
# the first payload_cap L4 bytes), frag ('first'|'later'|None), ip_id,
# tcp_window, tcp_mss, tcp_wscale, tcp_reserved.
class Signature:
__slots__ = ("name", "category", "proto", "sports", "dports",
"flags_value", "flags_mask", "payload_bytes", "payload_offset",
"payload_mask", "min_len", "max_len", "ttl_min", "ttl_max",
"tos", "icmp_type", "icmp_code", "tcp_mss", "tcp_window",
"tcp_reserved", "src_net", "dst_net", "frag",
"action", "rate_pps", "rate_bps", "log", "enabled",
"block_time", "min_packets", "min_percent", "_real_override",
"block_contributors", "auto", "source_ips", "uid")
_counter = 0
def __init__(self, name, category, proto=None, sport=None, dport=None,
tcp_flags=None, tcp_flags_mask=None, payload_hex=None,
payload_offset=None, payload_mask_hex=None,
min_len=None, max_len=None, ttl_min=None, ttl_max=None,
tos=None, icmp_type=None, icmp_code=None, tcp_mss=None,
tcp_window=None, tcp_reserved=None, src_ip=None, dst_ip=None,
frag=None, action=None, rate_pps=None, rate_bps=None, log=False,
enabled=True, block_time=None, min_packets=None,
min_percent=None, real_source=None, block_contributors=None,
auto=False, source_ips=None):
self.name = name
self.category = category
self.proto = proto_to_num(proto)
self.sports = self._portset(sport)
self.dports = self._portset(dport)
self.flags_value = flags_to_value(tcp_flags)
if tcp_flags_mask is not None:
self.flags_mask = flags_to_value(tcp_flags_mask) if not isinstance(tcp_flags_mask, int) else tcp_flags_mask
else:
self.flags_mask = default_flag_mask(self.flags_value)
if payload_hex:
ph = str(payload_hex).lower().replace(" ", "")
if not _is_hex(ph):
raise ValueError(f"payload_hex {payload_hex!r} is not valid even-length hex")
self.payload_bytes = bytes.fromhex(ph)
else:
self.payload_bytes = None
self.payload_offset = int(payload_offset) if payload_offset is not None else None
if payload_mask_hex:
pm = str(payload_mask_hex).lower().replace(" ", "")
if not _is_hex(pm):
raise ValueError(f"payload_mask_hex {payload_mask_hex!r} is not valid hex")
self.payload_mask = bytes.fromhex(pm)
else:
self.payload_mask = None
self.min_len = int(min_len) if min_len is not None else None
self.max_len = int(max_len) if max_len is not None else None
self.ttl_min = int(ttl_min) if ttl_min is not None else None
self.ttl_max = int(ttl_max) if ttl_max is not None else None
self.tos = int(tos) if tos is not None else None
self.icmp_type = int(icmp_type) if icmp_type is not None else None
self.icmp_code = int(icmp_code) if icmp_code is not None else None
self.tcp_mss = tcp_mss
self.tcp_window = int(tcp_window) if tcp_window is not None else None
self.tcp_reserved = tcp_reserved # int value, or "nonzero"
self.src_net = ipaddress.ip_network(src_ip, strict=False) if src_ip else None
self.dst_net = ipaddress.ip_network(dst_ip, strict=False) if dst_ip else None
self.frag = frag # None | 'later' | 'first' | 'any'
self.action = action.lower() if action else None
self.rate_pps = int(rate_pps) if rate_pps is not None else None
self.rate_bps = int(rate_bps) if rate_bps is not None else None
self.log = bool(log)
self.enabled = bool(enabled)
self.block_time = int(block_time) if block_time is not None else None
self.min_packets = int(min_packets) if min_packets is not None else None
self.min_percent = float(min_percent) if min_percent is not None else None
self._real_override = real_source
self.block_contributors = block_contributors
self.auto = auto
self.source_ips = source_ips or []
Signature._counter += 1
self.uid = Signature._counter
@staticmethod
def _portset(spec):
if spec is None:
return None
if isinstance(spec, (list, tuple)):
return set(int(p) for p in spec)
return {int(spec)}
@staticmethod
def _mss_match(value, spec):
if isinstance(spec, str) and spec.lower() == "missing":
return value is None
if value is None:
return False
if isinstance(spec, (list, tuple)) and len(spec) == 2:
return spec[0] <= value <= spec[1]
return value == int(spec)
@property
def spoofable(self):
return policy_for(self.category)["spoofable"]
@property
def needs_ct(self):
return policy_for(self.category).get("needs_ct", False)
@property
def strategy(self):
if self.action == "allow":
return "allow"
if self.action == "ratelimit":
return "ratelimit"
if self.action == "drop":
return "filter"
if self.action in ("block", "block_src"):
return "block_src"
if self.action == "report":
return "report"
return policy_for(self.category)["strategy"]
@property
def real_source(self):
if self._real_override is not None:
return bool(self._real_override)
return policy_for(self.category).get("real_source", False)
def matches(self, pkt):
if self.proto is not None and pkt.get("proto") != self.proto:
return False
# Non-first fragments carry no usable L4 header; only proto/len/frag/src
# fields are trustworthy. Reject any signature that needs L4 detail.
if pkt.get("frag") == "later":
if (self.sports or self.dports or self.flags_value is not None
or self.payload_bytes is not None or self.icmp_type is not None):
return False
if self.frag is not None:
pf = pkt.get("frag")
if self.frag == "any" and not pf:
return False
if self.frag in ("first", "later") and pf != self.frag:
return False
if self.sports is not None and pkt.get("sport") not in self.sports:
return False
if self.dports is not None and pkt.get("dport") not in self.dports:
return False
if self.flags_value is not None:
flags = pkt.get("flags")
if flags is None or (flags & self.flags_mask) != self.flags_value:
return False
if self.min_len is not None and pkt.get("len", 0) < self.min_len:
return False
if self.max_len is not None and pkt.get("len", 0) > self.max_len:
return False
if self.ttl_min is not None and (pkt.get("ttl") is None or pkt["ttl"] < self.ttl_min):
return False
if self.ttl_max is not None and (pkt.get("ttl") is None or pkt["ttl"] > self.ttl_max):
return False
if self.tos is not None and pkt.get("tos") != self.tos:
return False
if self.icmp_type is not None and pkt.get("icmp_type") != self.icmp_type:
return False
if self.icmp_code is not None and pkt.get("icmp_code") != self.icmp_code:
return False
if self.tcp_mss is not None:
if pkt.get("proto") != 6 or not self._mss_match(pkt.get("tcp_mss"), self.tcp_mss):
return False
if self.tcp_window is not None and pkt.get("tcp_window") != self.tcp_window:
return False
if self.tcp_reserved is not None:
rv = pkt.get("tcp_reserved")
if rv is None:
return False
if self.tcp_reserved == "nonzero":
if rv == 0:
return False
elif rv != int(self.tcp_reserved):
return False
if self.src_net is not None and not _ip_in(pkt.get("src"), self.src_net):
return False
if self.dst_net is not None and not _ip_in(pkt.get("dst"), self.dst_net):
return False
if self.payload_bytes is not None:
payload = pkt.get("payload") or b""
if self.payload_offset is None:
if self.payload_mask is not None:
return False # masked match requires a fixed offset
if payload.find(self.payload_bytes) < 0:
return False
else:
seg = payload[self.payload_offset:self.payload_offset + len(self.payload_bytes)]
if len(seg) < len(self.payload_bytes):
return False
if self.payload_mask is not None:
m = self.payload_mask
for i, b in enumerate(self.payload_bytes):
mb = m[i] if i < len(m) else 0xFF
if (seg[i] & mb) != (b & mb):
return False
elif seg != self.payload_bytes:
return False
return True
def _ip_in(ip, net):
if not ip:
return False
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return addr.version == net.version and addr in net
_SIG_FIELDS = ("proto", "sport", "dport", "tcp_flags", "tcp_flags_mask",
"payload_hex", "payload_offset", "payload_mask_hex", "min_len",
"max_len", "ttl_min", "ttl_max", "tos", "icmp_type", "icmp_code",
"tcp_mss", "tcp_window", "tcp_reserved", "src_ip", "dst_ip",
"frag", "action", "rate_pps", "rate_bps", "log", "block_time",
"min_packets", "min_percent", "real_source", "block_contributors")
def _build_signature(entry):
kw = {k: entry[k] for k in _SIG_FIELDS if k in entry}
return Signature(name=entry["name"],
category=entry.get("category", "unclassified"), **kw)
def load_signatures(cfg=None):
"""Load methods.json plus any auto-detected signatures.
cfg (optional) lets loading respect trusted_ports (disable signatures that
would hit your own service ports) and block_autodetected_patterns (learned
signatures are loaded report-only unless acting on them is enabled AND the
entry is marked approved)."""
sigs = []
names_seen = {}
trusted_ports = set(cfg["trusted_ports"]) if cfg else set()
act_on_auto = bool(cfg and cfg.get("block_autodetected_patterns"))
def _add(entry, auto=False):
try:
if not isinstance(entry, dict):
raise ValueError("signature entry is not an object")
if entry.get("enabled") is False:
return
sig = _build_signature(entry)
sig.auto = auto
# Reflection/amplification is defined by an OVERSIZED response. Apply a
# minimum-length floor so a signature never matches a small legitimate
# packet merely because it shares a service port (e.g. game servers).
if cfg and sig.category in ("reflection", "amplification") and sig.min_len is None:
sig.min_len = cfg.get("reflection_min_length", 64)
if auto and not act_on_auto and not entry.get("approved"):
sig.action = "report" # learned but not yet trusted to drop
ports = (sig.sports or set()) | (sig.dports or set())
if trusted_ports and ports & trusted_ports:
log.info("Disabling signature %r (hits trusted_ports %s)",
sig.name, sorted(ports & trusted_ports))
return
if sig.name in names_seen:
names_seen[sig.name] += 1
sig.name = f"{sig.name}#{names_seen[sig.name]}"
else:
names_seen[sig.name] = 0
sigs.append(sig)
except Exception as exc: # noqa: BLE001 - one bad entry must not kill load
log.warning("Skipping malformed signature %r: %s",
entry.get("name") if isinstance(entry, dict) else entry, exc)
try:
with open(METHODS_FILE, "r", encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
log.error("methods.json not found at %s", METHODS_FILE)
return []
except json.JSONDecodeError as exc:
log.error("methods.json is not valid JSON: %s", exc)
return []
raw = data.get("signatures", []) if isinstance(data, dict) else data
if not isinstance(raw, list):
log.error("methods.json 'signatures' is not a list")
return []
builtin = 0
for entry in raw:
before = len(sigs)
_add(entry, auto=False)
builtin += len(sigs) - before
auto_loaded = 0
try:
if os.path.exists(AUTODETECT_FILE):
with open(AUTODETECT_FILE, "r", encoding="utf-8") as fh:
auto_data = json.load(fh)
if isinstance(auto_data, list):
for entry in auto_data:
if isinstance(entry, dict):
entry.setdefault("category", "udp_flood")
# learned patterns are saved with "label" and a single
# "ttl"; map them to the signature fields so they reload
entry.setdefault("name", entry.get("label") or "Auto_learned")
if entry.get("ttl") is not None and entry.get("ttl_min") is None:
entry["ttl_min"] = entry["ttl_max"] = entry["ttl"]
# legacy v2 category names -> v4 categories
if entry.get("category") in ("valid_ip_attacks", "spoofed_ip_attacks"):
entry["category"] = "udp_flood"
before = len(sigs)
_add(entry, auto=True)
auto_loaded += len(sigs) - before
except (json.JSONDecodeError, OSError) as exc:
log.warning("Could not load auto-detected methods: %s", exc)
log.info("Loaded %d signatures (%d built-in, %d auto-detected)",
len(sigs), builtin, auto_loaded)
return sigs
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
CONFIG_TEMPLATE = """\
; NetDeflect settings
; Most people only need to set: [notification] webhook_url and [services] ports.
; Everything else has a sensible default. Run `python3 netdeflect.py --setup`
; for a guided setup, or `--selftest` to check your system.
[general]
; Default tuning for your machine. Any setting you change below still wins.
; vps - small VPS / single game server / VPN box (default, lightest)
; server - busier dedicated server
; enterprise - fleets and datacentres (enables metrics)
profile = vps
; Check GitHub for new NetDeflect releases (logs a notice, never auto-updates).
check_for_updates = True
; Find your GRE / IPIP / WireGuard tunnels automatically: their remote endpoints
; are trusted and the tunnel protocol is never treated as a flood.
auto_detect_tunnels = True
[ip_detection]
; How NetDeflect learns this server's public IP so it never blocks itself.
; Options: opendns, google_dns, ipify, icanhazip, local, none
ip_method = opendns
[capture]
; Network interface to protect. "auto" uses the interface of your default route.
; Several interfaces: eth0, eth1
network_interface = auto
; Where traffic is read from:
; afpacket - built-in sniffer on the interface above (recommended, 1-10G)
; tcpdump - same, via tcpdump
; tshark - same, via tshark
; sflow - listen for sFlow v5 from your router/switch (for 10G/40G/100G)
; netflow - listen for NetFlow v5/v9 or IPFIX from your router/switch
; sflow and netflow are out-of-band: NetDeflect never sits in the traffic path,
; so it cannot fail closed. Mitigation is sent upstream (set firewall_system = bgp).
; Configure the exporter and collector in the [flow] section below.
capture_method = afpacket
; Most packets examined per check. NetDeflect's CPU use depends on this number,
; not on how big the attack is.
packet_count = 5000
; Most seconds spent sampling per check.
capture_seconds = 5
; Look at 1 in N sampled packets. Raise on very fast links to save CPU.
sampling_rate = 1
; Bytes read from each packet for analysis.
snaplen = 128
; Bytes of each packet kept in saved capture files (0 = whole packet).
pcap_snaplen = 0
; Bytes of packet payload compared against signatures (0 turns payload checks off).
payload_cap = 64
; Save the packets of each attack as a .pcap file for later analysis.
save_captures = True
; Optional capture filter in tcpdump syntax, e.g. "not port 22". Usually empty.
filter_arguments =
[flow]
; Only used when capture_method is sflow or netflow. Point your router or switch
; here (it exports the samples; NetDeflect analyses them and mitigates upstream).
; Address to listen on (0.0.0.0 = all interfaces).
listen_host = 0.0.0.0
; UDP port the exporter sends to. Leave empty to use the usual port for the method
; (6343 for sFlow, 2055 for NetFlow/IPFIX). Set it if your router uses another.
listen_port =
; Only accept flow packets from these exporter addresses (comma separated). Leave
; empty to accept from any source on the port above (fine on a trusted mgmt VLAN).
agents =
; Scale counts back up by this ratio when the exporter does not state its own
; sampling rate (sFlow and NetFlow v5 carry it; many NetFlow v9/IPFIX exporters
; do not). Set it to the "1 in N" rate configured on your router, e.g. 2000.
sampling_rate = 1
[triggers]
; When to start mitigating.
; P = packets per second over pps_threshold
; M = megabytes per second over mbps_threshold
; MP = both over their thresholds (a threshold of 0 is ignored)
trigger_mode = MP
pps_threshold = 15000
; In megaBYTES per second (240 Mbit/s = 30).
mbps_threshold = 30
; Optional per-protocol triggers in packets per second (0 = off). Useful when one
; protocol floods while total traffic still looks normal.
threshold_tcp_pps = 0
threshold_udp_pps = 0
threshold_icmp_pps = 0
; Optional per-protocol triggers in megabytes per second (0 = off).
threshold_tcp_mbps = 0
threshold_udp_mbps = 0
threshold_icmp_mbps = 0
; How often (seconds) to sample per protocol while not under attack. Only used
; when a per-protocol trigger is set.
per_protocol_check_interval = 10
; A signature counts as part of the attack when it matches at least this
; percentage of sampled packets...
detection_percent = 3
; ...and at least this many packets.
detection_min_packets = 100
; Seconds to wait between mitigation rounds during an attack.
mitigation_pause = 20
; Seconds of normal traffic before an attack is declared over. Protection stays
; up during this time, so on/off "pulse" attacks are not let through in the gaps.
holddown_seconds = 120
[firewall]
; How blocks are enforced:
; nftables - recommended, fastest and most complete
; xdp - nftables PLUS xdp-filter: banned source addresses are also dropped
; in the NIC driver (XDP) before they reach the kernel. Needs the
; xdp-tools package and an XDP-capable interface; if either is
; missing it runs exactly as nftables. See xdp_mode below.
; ipset - iptables with ipset
; iptables - plain iptables (slow with many blocks)
; blackhole - null routes only (blocks addresses, cannot drop attack patterns)
; bgp - out-of-band: no local rules, ask upstream to drop the attacker
; prefixes over BGP (and/or an external API). The right choice with
; sflow/netflow and on 10G/100G links, where you cannot drop in
; userspace. Configure the session in [escalation].
firewall_system = nftables
; Only used when firewall_system = xdp. How xdp-filter attaches to the interface:
; native - in the driver, fastest (needs driver support; most 10G+ NICs have it)
; skb - generic, works on any driver (slower). Used automatically if native
; is not available.
; hw - offloaded to the NIC hardware (needs specific NICs)
xdp_mode = native
; Most source addresses held in the XDP drop map (the rest stay on nftables).
; xdp-filter's map holds at most 10000 per address family.
xdp_max_entries = 4000
; Most new addresses pushed into XDP per round (each one is an xdp-filter call;
; nftables already drops the rest, and they move into XDP over the next rounds).
xdp_adds_per_round = 512
; How long an automatic block lasts, in seconds (0 = until NetDeflect stops).
block_timeout = 3600
; Only filter traffic arriving on the interface(s) above. Leave True so Docker
; bridges, VPN links and other internal traffic are never touched.
scope_rules_to_interfaces = True
; Drop all incoming IPv6 (only if you do not use IPv6 at all).
block_all_ipv6 = False
; Set False to watch and report without blocking anything (same as --dry-run).
; A good way to run the first day on a new server.
enable_ban = True
dry_run = False
[services]
; Your own public ports: game servers, VPN servers, tunnel endpoints.
; Floods aimed at these ports are mitigated. Format: port/protocol, separated by
; commas. Protocol is udp, tcp or both. Ranges are allowed. Add :wireguard or
; :openvpn to also drop any packet that is not valid for that protocol.
; Example: ports = 7777/udp, 27015-27016/both, 25565/tcp, 51820/udp:wireguard
ports =
; Packets per second allowed from any single address to these ports in normal
; operation (0 = no limit). Game clients send well under 200 per second.
rate_per_source = 0
; Learn which addresses are your real players or VPN users while things are
; quiet, so they keep working first during an attack.
learn_clients = True
; Seconds between learning passes, and how long a learned client is remembered.
learn_interval = 120
client_memory_hours = 72
; During an attack, addresses that are not learned clients are limited to this
; many packets per second each...
lockdown_rate_per_source = 50
; ...and to this many packets per second in total, so a flood from thousands of
; fake addresses cannot swamp the port. Learned clients are not affected.
lockdown_total_rate = 5000
; Turn lockdown off entirely with False.
lockdown_during_attack = True
; Only lock down if at least this many clients have been learned (otherwise new
; players would all share the small budget above).
lockdown_min_clients = 5
[blocklist]
; Addresses or ranges blocked permanently while NetDeflect runs.
; Manage them live with --block, --block-net, --unblock and --list-blocks.
block_ips =
block_nets =
; A file of addresses or ranges to block, one per line (# for comments).
blocklist_file =
; Blocklist URLs fetched automatically, e.g. https://www.spamhaus.org/drop/drop.txt
blocklist_urls =
; Seconds between blocklist downloads (86400 = daily).
blocklist_refresh = 86400
; Drop packets to or from port 0, which never appear in real traffic.
drop_port0 = True
; Drop packets that arrive from outside claiming to come from this server.
land_drop = True
; Drop packets from private or reserved address ranges arriving on the public
; interface. Leave False if this server receives private-range traffic on it.
bogon_filter = False
[advanced_mitigation]
; Enable SYN cookies so SYN floods cannot exhaust connection slots.
enable_syn_cookies = True
; New TCP connections per second allowed from one address during a SYN flood.
syn_rate_limit = 200
; ICMP (ping) packets per second allowed from one address during an ICMP flood.
icmp_rate_limit = 50
; Packets per second allowed from one address for flooded query types (DNS ANY,
; game server queries) during an attack.
query_rate_limit = 100
; Always-on overall limits in packets per second (0 = off). Only set these if you
; know your normal traffic, since they apply to everyone.
blanket_udp_pps = 0
blanket_icmp_pps = 0
; Block the servers used in a reflection/amplification attack. The attack
; traffic itself is always dropped; this also blocks every reflector seen.
block_reflectors = True
; Smallest response, in bytes, that counts as amplification. Reflection signatures
; never match a packet below this size, so legitimate small packets from a service
; port (for example a FiveM or Source game server's own traffic) are never treated
; as an attack. Only genuine oversized amplification replies are caught.
reflection_min_length = 64
; Only block reflectors sending at least this percentage of sampled traffic.
; 0 blocks every reflector seen (recommended: big attacks use thousands of them).
reflector_block_percent = 0
; A reflector must be seen in at least this many sampled packets.
reflector_min_packets = 1
; Most reflectors blocked per round (protects memory on huge attacks).
max_reflector_blocks = 20000
; Never drop DNS replies from the resolvers this server uses.
exempt_resolvers = True
; IP fragments: off, ratelimit or drop. Real traffic is sometimes fragmented,
; so ratelimit is the safe choice.
fragment_action = ratelimit
fragment_rate_pps = 1000
; Alert when one of your /24 ranges is flooded even if no single address is
; over the threshold ("carpet bombing"). Packets per second, 0 = off.
carpet_pps_threshold = 0
; Block the sources of a flood when it comes from only a few addresses that
; each look genuine. Off by default because flood sources are usually faked.
block_flood_contributors = False
flood_contributor_percent = 30
flood_contributor_max_sources = 8
flood_contributor_block_time = 600
; Learn new attack patterns from attacks no signature recognises, by fingerprinting
; the whole packet (source port, TTL, TCP window, DSCP, TCP flags, length, payload)
; and building the most specific drop rule that still covers the attack.
enable_pattern_detection = True
; Automatically enforce a HIGH-confidence learned pattern (two or more matching
; header fields, or a shared payload) as a spoof-safe pattern drop. This is what
; stops an attack no built-in signature knows. Turn it off to only log them.
autodetect_enforce = True
; Also enforce LOWER-confidence learned patterns (one marker). Off by default;
; these are saved for you to review instead.
block_autodetected_patterns = False
; A header field counts as part of the fingerprint when this percentage of the
; attack's packets share it. Higher = stricter (fewer, more certain patterns).
autodetect_marker_pct = 85
; Guards so normal traffic is never learned as an attack:
autodetect_min_sources = 5
autodetect_min_percent = 15
autodetect_heavy_source_percent = 20
autodetect_block_time = 900
; Last resort for unrecognised attacks: block the biggest senders.
enable_fallback_blocking = False
contributor_threshold = 30
; Block a whole /24 (IPv4) or /64 (IPv6) once this many of its addresses are blocked.
subnet_aggregate_count = 16
; Number of saved capture files to keep (0 = keep all).
max_pcap_files = 20
[conntrack]
; Connection tracking is off by default. NetDeflect does not need it, and under a
; flood of fake addresses it costs CPU and memory. Turn it on only for the extra
; protections below.
enable_connection_tracking = False
; Drop packets that do not belong to any connection (ACK/RST/FIN floods).
drop_invalid = False
; Drop reflection replies this server never asked for.
reflection_ct_drop = False
; Most simultaneous TCP connections from one address to the ports below
; (stops slowloris-style attacks). 0 = off.
conn_limit_per_source = 0
conn_limit_ports = 80, 443
; Warn when the connection table is this full (percent).
conntrack_warn_percent = 80
[whitelist]
; Addresses that are never blocked. Your gateway, this server, your SSH session
; and the DNS resolvers you use are protected automatically.
trusted_ips = 8.8.8.8, 8.8.4.4, 1.1.1.1, 1.0.0.1, 9.9.9.9
; Address ranges that are never blocked, e.g. your office or monitoring.
trusted_networks =
; Never block private address ranges (192.168.x.x, 10.x.x.x and so on).
protect_private_ranges = True
; Never block the DNS resolvers listed in /etc/resolv.conf.
protect_resolvers = True
; Tunnel protocols you use, so they are never treated as a flood: gre, esp, ah, ipip.
protocols_in_use =
; Addresses or ranges whose traffic skips all checks: other servers you run,
; tunnel endpoints, admin machines.
trusted_peers =
; Ports whose traffic skips ALL checks. This leaves them unprotected, so use
; [services] for game and VPN ports instead. Only for things like monitoring.
bypass_ports =
[escalation]
; Ask your upstream network to drop traffic using BGP. This is the only fix once
; your connection itself is full, and it is the mitigation path for the "bgp"
; firewall backend (where attacker prefixes are announced for your upstream to
; drop, which needs BGP FlowSpec or source-based RTBH). Needs a BGP session with
; your provider. Off by default. Set this True when firewall_system = bgp.
bgp_enabled = False
; exabgp - write commands to an ExaBGP pipe. hook - run a script you provide.
bgp_mode = exabgp
exabgp_command_pipe = /run/exabgp/exabgp.cmd
escalation_hook_script =
; BGP community your provider uses for blackholing.
rtbh_community = 65535:666
rtbh_next_hop =
; Your connection speed in Mbit/s. Escalate when traffic reaches 90% of it.
; 0 = never escalate on traffic volume.
uplink_capacity_mbps = 0
; Also escalate when the network card itself starts dropping packets for this
; many rounds in a row.
escalate_on_saturation = False
saturation_rounds = 3
; Prefix length announced (32 = just the attacked address).
announce_prefix_len = 32
; Most attacker prefixes announced upstream at once (firewall_system = bgp).
; Providers set a max-prefix limit on the session, and going over it drops the
; whole session, so keep this below that limit. The blackhole of your own
; address when the uplink is full is always sent. 0 = no limit.
max_announced_prefixes = 500
; Seconds after the attack ends before the announcement is withdrawn.
escalation_withdraw_after = 300
; What to do about carpet bombing: alert, or rtbh (announce the whole range).
carpet_action = alert
[metrics]
; Prometheus metrics. textfile writes application_data/netdeflect.prom for the
; node_exporter textfile collector; http serves /metrics on the address below.
prometheus_enabled = False
prometheus_mode = textfile
prometheus_host = 127.0.0.1
prometheus_port = 9209
[notification]
; Discord webhook for attack alerts. Leave as is to turn Discord alerts off.
webhook_url = https://discord.com/api/webhooks/CHANGE-ME
; How Discord alerts look:
; rich - built-in embed, coloured by result (green mitigated, amber partial,
; red not yet stopped), with a plain-English summary, what is being
; dropped, target ports, sources, learned patterns and any action
; you need to take (recommended)
; template - your own notification_template.json with {{placeholders}}
discord_style = rich
; Optional ping with each attack alert, e.g. @here, @everyone or a role as <@&ROLE_ID>.
; The "attack ended" summary never pings.
discord_mention =
; Plain text webhook for Slack, Microsoft Teams or Mattermost (optional).
text_webhook_url =
; Send a summary when an attack ends.
notify_on_end = True
; Send an alert every mitigation round (False = only when an attack starts,
; when new attack types appear, and when it ends).
notify_every_round = False
; Script to run on every attack. It receives the attack details as JSON on
; standard input (a few lines of curl forwards it to Discord, Telegram or a BGP
; tool). Arguments: action target_ip attack_vector dropped_pps attack_id.
notify_script =
; json sends the full report on standard input; args passes values as arguments.
notify_format = json
notify_script_timeout = 10
[logging]
; Log file (rotated automatically). Leave empty to log to the screen only.
log_file = application_data/netdeflect.log
; DEBUG, INFO, WARNING or ERROR
log_level = INFO
; Write one JSON line per attack to this file, for SIEM tools. Empty = off.
event_log =
; Attack reports and address lists to keep (oldest are removed first). 0 = keep all.
max_report_files = 1000
[display]
; Live status screen: auto (on when run in a terminal), on, off.
live_dashboard = auto
; Coloured output: auto, always, never.
colour = auto
; Seconds between status updates.
stats_interval = 2
[external_firewall]
; Send blocked addresses to an outside firewall or provider API.
enable_api_integration = False
api_endpoint = https://api.example.com/firewall/block
; none, bearer, basic or header
auth_method = bearer
auth_token = your_api_token_here
auth_username =
auth_password =
additional_headers = {"Content-Type": "application/json"}
request_method = POST
; all = one request, batch = groups of max_ips_per_batch, single = one per address
sending_mode = all
max_ips_per_batch = 100
; {{IP_LIST}}, {{IP_CSV}}, {{IP}}, {{TIMESTAMP}} and {{SOURCE}} are filled in.
request_body_template = {"source": "NetDeflect", "timestamp": "{{TIMESTAMP}}", "ips": {{IP_LIST}}}
request_timeout = 10
"""
# Profile defaults: applied only to keys the operator did NOT set.
PROFILES = {
"vps": {},
"server": {
("capture", "packet_count"): "8000",
("services", "lockdown_total_rate"): "20000",
},
"enterprise": {
("metrics", "prometheus_enabled"): "True",
("capture", "packet_count"): "10000",
("advanced_mitigation", "subnet_aggregate_count"): "8",
("services", "lockdown_total_rate"): "50000",
},
}
class ConfigError(Exception):
"""settings.ini could not be loaded; the message lists every problem."""
def write_default_config(path=SETTINGS_FILE):
with open(path, "w", encoding="utf-8") as fh:
fh.write(CONFIG_TEMPLATE)
def parse_ip_list(text):
"""Split on commas/whitespace and keep valid IPs or CIDRs."""
out = []
for token in str(text or "").replace(",", " ").split():
try:
ipaddress.ip_network(token, strict=False)
out.append(token)
except ValueError:
log.warning("Ignoring invalid IP/CIDR entry: %r", token)
return out
def _parse_ports(text):
out = set()
for token in str(text or "").replace(",", " ").split():
token = token.strip().split("/")[0]
if "-" in token:
a, _, b = token.partition("-")
if a.isdigit() and b.isdigit() and 0 < int(a) <= int(b) <= 65535:
out.update(range(int(a), int(b) + 1))
continue
if token.isdigit() and 0 < int(token) <= 65535:
out.add(int(token))
elif token:
log.warning("Ignoring invalid port entry: %r", token)
return out
def _parse_csv(text):
return [t.strip() for t in str(text or "").replace(",", " ").split() if t.strip()]
# Service presets: defaults for well-known kinds of service port.
SERVICE_PRESETS = {
# Ordinary game server: per-source cap from config, lockdown on.
"game": {"known_rate_pps": 2000},
# WireGuard: only the four WireGuard message types are valid; a VPN user can
# legitimately send a lot, so no per-source cap and no cap on known clients.
"wireguard": {"proto": "udp", "min_len": 60, "known_rate_pps": 0, "rate_pps": 0},
# OpenVPN (UDP): only valid opcodes; no per-source cap for the same reason.
"openvpn": {"proto": "udp", "known_rate_pps": 0, "rate_pps": 0},
# Minecraft Java and other TCP games: limits apply to new connections (SYNs).
"minecraft": {"proto": "tcp", "known_rate_pps": 0},
# Listed only: learned clients and lockdown, nothing else.
"none": {"rate_pps": 0, "known_rate_pps": 0},