Added
- A sensor without LOG_SERVER (sensor and server on one host) sent no heartbeats, so a dead local sensor looked exactly like a quiet network. It now writes the heartbeat to LOG_DIR/.heartbeat, which the server reads with the file's mtime as last seen, so the silence and capture-drop alerts cover the single-host install too.
- The heartbeat carries the capture's received and dropped packet counts. /sensors and the Sensors view show the dropped share, and the server alerts once when a sensor drops more than 1% of its packets within an hour (SENSOR_DROP_ALERT, SENSOR_DROP_WINDOW), and once when that stops. Before, kernel drops never left the sensor host.
- Alerts: dropped alerts are counted in the log, /sensors shows webhook delivery status, and LOG_DIR/alerts.json keeps the tail position and queue, so a restart neither loses pending alerts nor skips events written while the server was down (up to 1,000). Senders whose clock is outside LOG_SERVER_SKEW are counted and shown; their events, dated -2 d or 1979, used to land in other days' logs and never alert.
- Sensor: ERSPAN type I and type III (with the optional platform sub-header), MPLS label stacks up to 8 deep, and Ethernet over MPLS with or without a control word are decapsulated.
- A sensor that stops sending heartbeats for 15 minutes (SENSOR_STALE_AFTER) is alerted on once, and once when it reports again; with or without a webhook it goes to error.log. The sensor table is kept in LOG_DIR/sensors.json, so a restart still knows who should be reporting.
- Dashboard: a day is parsed and aggregated in a Web Worker (js/worker.js, the same engine the page runs in js/engine.js) and the page applies its deltas, so the table stays usable while a day loads. A million-event day blocked the page for 4.6 s in 58 tasks; it blocks for 1.5 s in 15 now, and a 200k-event day for 0.3 s instead of 1.1 s. The day then holds two copies (worker and page): 350 MB instead of 240 MB for a 60k-event day, and the biggest day completes 15-30% later. No worker (or one that fails to load or crashes) means the in-thread path, as before; localStorage.mt_worker = "0" forces it.
- Static trail files carry TLS fingerprints typed, one per line: ja3:, ja4:, cert:. The assembler publishes the bare value; check_trails.py fails a value that does not fit its type, warns on an untyped one, and no longer rejects JA4 as "not a valid DNS name". Certificate trails may be SHA-256 as well as SHA-1.
- The sensor keeps the trail tables it builds next to TRAILS_FILE (<TRAILS_FILE>.store) and maps them on the next start, -T or reload instead of parsing the CSV. With 1.9M trails on one core, -T takes 0.08 s instead of 0.78 s, a start 0.18 s instead of 0.89 s, and peak RSS drops from 145 MB to 89 MB. The store is used only when it was built from the exact current CSV (content included), whitelist and sensor version. 'maltrail-sensor --verify-trail-store' checks it; TRAIL_STORE_CACHE false turns it off.
- A static ARMv7 sensor build with NEON (armv7-unknown-linux-musleabihf) for the Pi 2 and later on a 32-bit OS, which ran the ARMv6 build until now. install.sh picks it only when /proc/cpuinfo reports neon, vfpv4 and idiva, and falls back to the ARMv6 build otherwise.
- DNS responses are checked for listed CNAME targets. A client asking for an innocent-looking name the resolver aliases to a listed one never looked the listed name up, so nothing matched. The event is reported like the query it answers (source the client, destination the resolver), its trail names both ("(x).evil.com (innocent.org)"), and a response yields at most one event; targets are matched against trails only, not judged by the heuristics.
- Heuristic 'icmp_tunneling' (icmpsh, ptunnel, ICMP exfiltration): more than 10 distinct echo payloads from one host to one destination that match no ping tool's pattern. Real iputils ping (default, -s, -p), mtr and busybox ping raised nothing; a 40-request tunnel was reported.
- Heuristic 'idn_homograph': a queried name whose xn-- label, with its Cyrillic/Greek/Armenian lookalike letters read as Latin, becomes a whitelisted domain it is not (xn--pple-43d.com, "аpple.com"). Every IDN in the Cisco and Alexa top 1M lists (3,291) raised nothing.
- Right-click -> "Reverse DNS" for an event's addresses, resolved by the server on click (#19613)
- SESSION_EXPIRATION_HOURS: how long a dashboard login lasts (default 24) (#106)
- Format gate for every data/*.txt (sensor/tools/check_data.py), run on any data/ change: their loaders skip a line they cannot read, so a typo there passed CI and did nothing (#19624). It found a whitelist entry that was never whitelisted (inline comment), three IPv6 worst-ASN ranges the loader never read (now supported), and UA patterns that compiled differently per Python version
Changed
- Windows: built with Rust 1.100, the sensor bound WaitOnAddress to vertdll.dll (through pcap's windows-sys 0.36) and did not load under Wine; the system synchronization library is linked first, as with earlier toolchains.
- Alerts: one message per threat per day (ALERT_THROTTLE 86400), keyed on the dashboard's threat id, which %(threat)s now carries. Over 7 days of a home network: high 10 -> 4, medium 656 -> 90, low 8,982 -> 1,099.
- Dashboard: a copy Chrome renders invisibly ahead of a predicted visit (Settings > Performance > Preload pages) used to fetch the whole day's /events, a full file scan on the server, for a page that might never be shown. The day is loaded once the page is actually shown.
- Server: text event lines are split by csv's C reader (same quoting as safe_value()); a cold /fail2ban of a 1M-event day went from 9.7 s to 6.7 s.
- Server: static files are compressed once per modification and the trail set once per validator and entitlement: a /trails poll of a 95 MB set cost 2.1 s of CPU and ~110 MB of memory every time, now once; 100 requests for main.js went from 1.3 s to 0.1 s of CPU.
- Dashboard: progressive repaints during a day load are paced by their own measured cost; a 1M-event day on a 4x-throttled CPU loads in 22 s instead of 32 s, unchanged at full speed.
- Server: a restricted analyst's CIDR was expanded into a regex of up to 65,536 alternatives and run over every line of every scoped read. The CIDR stays a CIDR, each megabyte is sieved for the scope's fixed octets before any line is decoded, and the destination gets the same fast accept as the source: on a 1M-event day a /24 analyst's /events went from 3.0 s to 0.4 s and /counts over a month from 7.6 s to 0.8 s.
- User-Agent reports the machine (aarch64, x86_64) instead of the pointer width ("x64").
- A host without the memory for one dict builds the trail set streamed: 1,762,779 of 1,763,354 rows are new to the merge, so a 4 MB bit filter finds the few trails seen more than once, only those are held, and the rest go straight to trails.csv. Byte for byte the same file; 52 MB peak and 27 minutes on a Pi Zero squeezed to 51 MB free, where SQLite took over an hour. The SQLite store (core/disktrails.py) is removed.
- A trail reload parses with one thread fewer than the cores, so the capture worker keeps a core. Parsing trails.csv is ~15% faster (0.80 s -> 0.68 s on one core).
- A Pi Zero starts in 1.9 s instead of 5.8 s with the trail store:
- the store no longer hashes all of trails.csv on every start when the file's inode and ctime show it untouched since the store was built (ext4, XFS, btrfs, F2FS, tmpfs, overlayfs; anything else is hashed);
- data/ua.txt lines are syntax-checked instead of each being compiled (1.8 s on a Pi Zero);
- static musl builds use mimalloc: musl's own malloc was a third of the start. 12% less time per packet as well; glibc builds are unchanged.
- The trail loader no longer "prefetches" table slots ahead of each insert. It was a real load, which an in-order core (ARM11, Cortex-A53) stalls on: 18% slower inserts on a Pi Zero, and the whole load was 9% slower with it on a Ryzen too (0.69 s -> 0.63 s).
- The sensor's fast hash starts from a random per-process state, so colliding source addresses cannot be computed in advance.
- Scan heuristics keep threshold+1 items per key instead of up to 1024, and DNS tunneling judges distinctness on a pair's first 256 queries and stops recording once it has reported: a flood could fill gigabytes.
- Stale trails are reported every update cycle instead of once per process (error.log and a new maltrail_trails_age_seconds metric), and a trail update that hangs is killed after an hour instead of blocking reloads.
- meta.sqlite is written by one thread, not by the capture workers: a worker hands its window over in ~10-90 us instead of stalling capture for the write. The write itself is one sorted upsert per row.
- The trail hash table is sized to its entries instead of the next power of two: 109 -> 74 MB for the full trail set (RSS 158 -> 122 MB).
- The trail store is built file-backed only on hosts with under 2 GB of RAM. Its pages are reclaimable only after being written back, so every start and reload wrote the whole store to disk (125 MB measured on a Pi 4, to its SD card) even where the heap holds it comfortably. A Pi 4 now writes ~5 MB in its first minute instead of 125 MB.
- DNS queries for new names and subdomains are 25-37% cheaper (subdomain query 660 -> 430 ns, cold 405 -> 300 ns). The tunneling heuristic decides a zone's exemption once per (source, zone) pair instead of per query, the exempt-zone set no longer uses SipHash, both DNS accumulators share one hash of each name, the domain caches hash a name once per lookup and insert, and short-key hashing no longer calls memcpy. Events unchanged.
- potential remote code execution heur is improved (Issue #19646).
- JA3, JA4 and certificate SHA-1 digests are computed only when the trail set holds a key of that shape. The shipped trail set holds none, so a TLS handshake no longer pays for an MD5, two SHA-256s and a SHA-1 that could never match (ClientHello path ~5,300 -> ~1,000 instructions). DNS names are decoded into a reused buffer and checked in fewer passes, and the domain caches evict with SIEVE instead of LRU, so a hit sets a bit instead of relinking a list (warm query 230 -> 171 ns, cold 478 -> 401 ns). On the reference network with the shipped trail shapes the packet path runs 38% fewer instructions (757M -> 467M) and replays 31% faster, events unchanged.
- Faster packet path: 46% fewer instructions per packet on the reference network (500 hosts, 208k packets), with byte-identical events. Release builds use fat LTO (9-17% on every benchmark row); JA4's SHA-256s are memoized per worker and a ClientHello parse reuses its buffers instead of a dozen allocations; DNS subdomain queries no longer allocate per name in the exhaustion/tunneling accumulators (1100 -> 730 ns); a QUIC Initial skips server packets and cached keys, and decrypts in batches (2980 -> 900 ns).
- maltrail.conf cleanup. CAPTURE_FANOUT is CAPTURE_WORKERS (they were the same number), CHECK_TLS_CERTIFICATES is CHECK_TLS_FINGERPRINTS (it also switches JA3/JA4), and CAPTURE_BUFFER, the Python sensor's ring, is no longer mandatory: its 10% is the default, so a stock install keeps the same 64-256 MB libpcap ring. The old names are still read. PROCESS_COUNT is no longer set: it only ever applied to EVENT_THROTTLE_MODE legacy.
- The default CAPTURE_FILTER keeps QUIC's data packets out of the capture ring and passes only its handshake (the long-header packets, which carry the SNI), the way it already passed only the start of a TCP connection. QUIC is about half of what the old filter passed on a real transit link (MAWI, Oct 2026). One worker under a 3 Mpps QUIC-heavy load went from 11% dropped and 8,880 of 10,000 planted detections to nothing dropped and all 10,000. An existing maltrail.conf keeps its filter until it is updated.
- DNS events on a subdomain throttle per zone: '(label).zone' trails (long domains, DGA verdicts, a listed TLD or parent domain) share one budget per host, zone and detection, the grouping the dashboard already shows. A DNS tunnel or a DGA bot names a new label every time, so each wrote one event per query: 220 for one tunnel and 177 for one bot in the reference network, now 3 per minute and one '(*).zone' summary line.
- The previous/next day arrows skip days with no events, stopping at today (#19614)
- Custom trails get their own class icon in the info column (#19623)
- "potential web scanning" names the paths the scanner tried (/admin, /wp-login.php, ...) instead of logging a bare * (#18247); the dashboard still groups, folds and whitelists them as *
- "another instance already running" names the process holding the port, when /proc can tell (#19611)
Fixed
- Login: /login/params answered legacy: true only for accounts with an old sha256 USERS entry, which told anyone which of those usernames existed. The server now derives the salted verifier from that digest itself (it is the PBKDF2 input), so such an account gets the same parameters as a missing one and the browser always sends the proof; the old digest no longer crosses the wire. Scripts sending the digest still log in.
- install.sh on macOS: Homebrew no longer builds bottles for Intel Macs, so 'brew install git python3 libpcap' compiled git, Python and OpenSSL from source (45-54 min) and upgraded the operator's installed packages on the way. libpcap is part of macOS; brew is now asked only for git or python3 when they do not work, and never upgrades.
- install.sh: re-running it (the upgrade path) left the old server and sensor running until a reboot, because 'systemctl enable --now' does nothing for an active unit. Units are restarted.
- Sensor: header names in another case ('host:', 'HOST:', 'user-agent:') skipped Host, URL and User-Agent trails; an AAAA-only lookup of a listed domain was never checked; DNS over TCP/53 was not parsed. Each now detects (crafted pcaps 0 -> 1 event; clean and PTR controls 0). A same-name A+AAAA pair within a second is still one event. DNS query path +8 ns.
- Login: an entry in the old sha256 form accepted a nonce the server never issued, so a captured login replayed after a restart. --doctor and the server's startup now say plainly that an empty USERS serves the event log unauthenticated (an error when not on loopback).
- Remote logging: the sensor's UDP socket was unconnected, so a stopped server was invisible (a 0.35 s server restart lost 136 events, and written=5 was reported with 0 of 5 arrived). Refusals now count as remote errors with one error.log line per outage and one on resume, and the heartbeat reports events sent, so /sensors shows what was lost on the way.
- CAPTURE_FILTER passed TLS only on 443 and HTTP only on six ports, so a hello on 8443/993 or a request on any other port never reached the sensor (2,103 trails carry host:port/path), and DNS over TCP and MPLS frames were dropped. TLS is matched by its record header and HTTP by method on any port; on a home network the packets passed went slightly DOWN, as the old 443 clause leaked bulk segments.
- Server: a failed event log write was swallowed, and the old handle was closed before the new one opened, so the receiver logged nothing until midnight (1,101 events lost after one transient chmod, error.log empty). Failures are reported, counted in /sensors and recovered from; a day log renamed away by logrotate is reopened.
- Trail update: with the static source failing and a 90-day-old cache, the updater exited 0 and -T and --doctor reported the trails as 0.0 days old, because every rebuild touched trails.csv. Age is now the content's age, a failed refresh exits 1, and a payload that is not trail CSV (an "Access denied" page served with 200) or shrinks below TRAIL_RELOAD_MIN_RATIO is refused instead of replacing the static trails.
- Sensor: a SYN carrying ECN flags (ECE+CWR) skipped IP and IP:port trails and the scan and beacon state (83 of 10,769 SYNs in an hour on a home network). The shipped CAPTURE_FILTER also dropped what the parser can unwrap: GRE, ERSPAN, IP-in-IP, 6in4, PPPoE, VLAN/QinQ and IPv6 extension headers now pass.
- ALERT_FORMAT: the shipped value contained '#%(threat)s', and the configuration reader cut it at the '#', so every webhook body was invalid JSON. It reads 'threat %(threat)s' now.
- install.sh on Arch: a box behind its package lists failed to install git ('openssl-3 ... exists in filesystem') and the installer died later with "'git' is still missing". It now stops at once and says to run 'pacman -Syu' first.
- Dashboard: a throttled or condensed line holds several events, but counted as one: a replayed day showed 286 events where the truth was 7,126 (now 7,064, 145 of 147 threats exact). /counts uses the same rule.
- Dashboard: a scanner's test strings in the User-Agent and its "Nikto (*)" summary were separate threats; one scanning tool is now one threat.
- Sensor: a CNAME listed like its query (checkip.amazonaws.com -> ...a2z.com) showed as two threats per lookup; it is reported once under the queried name.
- Dashboard: clicking a saved view applied nothing, and trails named like proto or constructor returned Object prototype members from the caches.
- Server: /live started at the end of the day's file even when that end was inside a line the sensor was still writing, so the rest of that line reached the dashboard as a fragment it could not parse: one event lost on every stream opened mid-append. It starts at the line's first byte now.
- Server: the listen backlog was 5, so 16 simultaneous requests made the slowest wait 1 s on a SYN retransmit and 32 wait 2 s (two dashboards refreshing together were enough); 150 idle sockets held every handler and got every real request a 503 for the whole REQUEST_TIMEOUT, before any login. Backlog 128, MAX_CONNECTIONS_PER_IP (default 32), and an idle connection is dropped after 10 s.
- Dashboard: stepping to another day reported all of that day's threats as new (44,717 after one click, accumulating with every day visited); the count is per viewed day now. Live updates are on unless switched off, and the switch is remembered.
- Dashboard: at phone width the wrapped header was painted over the first two cards; it grows and scrolls away now, and a threat card lost the rows that earn nothing on a phone.
- First run on live traffic (3 hours, home network) logged 1,406 events, 1,303 of them false positives. Replayed through the fixed sensor: 103. UDP scanning (501): BitTorrent tracker REPLIES were counted as the client scanning - a datagram answering one we saw sent no longer counts. Periodic beaconing (56 -> 5): visitors connecting IN to a local server, and a torrent client retrying its swarm peers, are no longer beacons. Long domain (50 -> 0): Amazon device telemetry (a2z.com, aws.dev), googleapis.com and HBO Max's CDN (max.com). Every static hit, the scanner user agent, web scanning and the RCE attempt are unchanged.
- A poller hitting the same trail was never summarized by the event throttle: once a minute, 748 events in 3 hours; every five minutes, 24 an hour all night. A key that recurs within an hour of its last event now goes into a summary, the window doubling up to an hour.
- One network blip at update time dropped sslbl (certificates, JA3) and Tor exit trails for a whole day; an empty or failing feed is now retried in place (after 10 s, then 30 s).
- Stopping the sensor left its trail updater running, and the next sensor started a second one on the same trails.csv; the updater now dies with the sensor and removes its temp files.
- One Nikto run from a LAN host logged 6,502 lines in 15 minutes: a scanner requests a new URL every time and Nikto puts its test id in the User-Agent, so no two events shared a throttle key. Web heuristics now throttle per host and detection (host()) and per scanner (Nikto ()), as the dashboard already groups them: the same pcap, 82 lines, every detection still reported.
- Beaconing and ICMP tunneling no longer judge traffic to private addresses: a C2 server or a tunnel's far end is not on the LAN. Live, that was 7 of 10 beaconing events (a Chromecast, the router's UPnP) and every ICMP tunneling event (a Mac pinging its router).
- The daily trail update took over 70 minutes on a Pi Zero: every host under 768 MB of available memory merged the 1.9M trails in SQLite, one SQL statement per dict operation. A Pi Zero (226 MB available next to a running sensor) now builds in one dict, and the whole update, feeds included, takes 16 minutes. The build itself is 30% faster everywhere (11.0 s -> 7.7 s for 1.9M trails on a desktop).
- JA3/JA4 trails never matched QUIC (HTTP/3): only the SNI of a QUIC ClientHello was read. Replayed over 974 sandbox captures against every fingerprint tshark computes there, QUIC went from 0 of 16 matched (source, fingerprint) pairs to 16 of 16, for JA3 and JA4 alike.
- JA4 was wrong for every ClientHello without signature algorithms (TLS 1.0/1.1 clients): its last part hashed "_", where the spec and Wireshark hash "". Fixed in the sensor and in core/tls_intel.py; such a published JA4 can match now.
- REPAIR_TRUNCATED_TRAILS false was ignored: options without a USE_/CHECK_ style prefix arrive as text, and it was read as a boolean only.
- On hosts with under 2 GB of RAM, building the trail tables took 257 s on a Pi Zero and wrote 1.9 GB to its SD card, on every start and reload: they were built in a file mapping, and random inserts re-dirtied pages the kernel had already written back. With the trail store on, they are built on the heap and written once, as the store: 25 s and 100 MB. A Pi Zero now starts in 4 s with the store, 40 s without it.
- Multi-worker capture with CAPTURE_FANOUT_MODE source sent a host's outgoing packets to the wrong worker whenever the destination MAC began with 0x4 or 0x6: on transmit the kernel hands the fanout program the Ethernet header, which it mistook for an IP header. 1,000 outgoing packets from 40 sources all went to worker 0 of 4; they now split 250/250/250/250. The program reads the kernel's protocol and network header offset instead of guessing.
- A USERS entry no longer works as its own password. It stored sha256(password), and logging in proved knowledge of exactly that, so anyone who could read maltrail.conf could log in. Entries are now salted PBKDF2 with a SCRAM-style proof ('python3 server.py --hash-password'; install.sh writes this form), and the login nonce is issued by the server, so a captured login cannot be replayed after a restart. Old sha256 entries still work, with a warning at start and in --doctor.
- error.log was created world-writable (0666), so any local account could append to it. It is 0644 now, owned by the log directory's owner, and an existing world-writable one is tightened. The sensor also prints a parser panic once per location rather than once per packet (a crafted packet replayed at line rate flooded journald), and its capture counters no longer wrap at 2^32 packets (12 hours at 100k packets/s).
- install.sh installs the latest release by default instead of master. The prebuilt sensor exists only for releases, so a master tree got an older sensor, which ignored options newer than itself. '--ref master' still works and says that the two differ.
- /events capped a date range at nothing: one request for 0001-01-01_9999-12-31 was 3.65 million stat() calls. It uses the same 92-day cap as every other endpoint now. Its open log handle was also kept on the session between Range requests, so two tabs read the same file object and a request for one day could be answered from another.
- The published admin/changeme! login is accepted only from the server itself; over the network it is refused with a message saying to set USERS. The shipped maltrail.conf listens on 0.0.0.0, so every install that kept it was open to anyone who had read the README. install.sh now generates a password of the install's own and prints it once.
- Fragmented IPv4 traffic is inspected: the kernel reassembles it (CAPTURE_FANOUT_DEFRAG, now on by default; one worker joins a fanout group of its own for it). A DNS query, HTTP request or ClientHello split into IP fragments was never seen before.
- The server's dashboard comes up at once instead of after the first trail update, which runs in the background (20 s update: /ping answered after 0.5 s instead of 20.5 s).
- With USE_SSL, one client that connected and never sent anything locked every other user out of HTTPS, indefinitely: the TLS handshake ran on the thread that accepts connections, with no timeout. It runs in the request's own thread now, with a 10 s limit.
- /fail2ban and /blacklist returned nothing whenever today's log held a byte the locale could not decode (any non-ASCII byte under a C locale, the usual one for systemd units and containers), so fail2ban banned no one. They also rescanned the whole day every 8 s (~7 s of CPU per 65 MB day); they read only the appended bytes now.
- Alert webhook failures (4xx, 5xx, timeouts) were reported as delivered and lost. They are retried with backoff from a queue, on their own thread, and logged. Downloads no longer silently retry HTTPS over plain HTTP, which had exposed the webhook URL and the static trails' checksum to anyone on the path.
- A live sensor with a trails.csv already on disk starts capturing at once and updates in the background. It used to detect nothing until the update finished, which can take minutes, on every start.
- The server lost most remote-sensor events whenever it was busy. Its UDP receiver shared one Python interpreter with the dashboard and the trail update, and with one other thread busy it lost 98% of 5,000 events/s. It runs as its own process now: 0% at 20,000/s with the rest busy.
- Datagrams the server refuses (wrong LOG_SERVER_SECRET, clock skew) are counted per sender and listed under sensors on the dashboard. Such a sensor's heartbeats are refused too, so it used to just look dead.
- The sensor resolved LOG_SERVER once and kept that address for good, while heartbeats re-resolved: after the server's IP changed it showed alive while every event went to the old address. All remote endpoints are re-resolved every minute, off the capture threads.
- A failed static-trails download no longer replaces a working trails.csv with a feeds-only build (a few percent of the set), which the server would then hand to every sensor pulling from it. The old file is kept and the update reports the failure.
- TLS and QUIC ClientHellos longer than one packet are put back together. Post-quantum key shares made a browser's ~2 KB, and Chrome's shuffled extension order puts the SNI in the second TCP segment about half of the time (122 of 240 real Chromium handshakes); every Chrome QUIC ClientHello spans two Initials. On those captures the sensor read 118 of 240 SNIs and no QUIC one; now all of them, and JA3/JA4 come from the whole ClientHello. Fixed memory, 64 x 4 KB per worker; no measurable cost on other traffic.
- IPv6 TCP was mostly invisible with the shipped CAPTURE_FILTER: libpcap's tcp[] reads IPv4 only, so no IPv6 SYN (outside the HTTP/proxy ports) and no IPv6 TLS handshake was captured, and SNI was read from IPv4 only. The filter now spells IPv6 TCP with ip6[] offsets and also passes the second segment of a ClientHello. An existing maltrail.conf keeps its filter until it is updated.
- /fail2ban no longer publishes the source of 'potential iot-malware download': since #19622 that event fires only when one of OUR hosts fetches the dropper, so the shipped FAIL2BAN_REGEX handed fail2ban the infected machine it is meant to protect.
- Dashboard: a heuristic on a bare TLD ('(label).com') from 50+ hosts no longer folds every OTHER heuristic on that TLD into the same flood threat (a DGA bot vanished inside 60 hosts' long-domain events), and a throttle summary line ending in '(+N more)' joins its threat instead of opening a second one.
- The multi-worker startup warning "fanout hashes by flow" no longer appears with CAPTURE_FANOUT_MODE source, the default, where it was false.
- DISABLED_HEURISTICS udp_scanning / web_scanning now stop the per-packet tracking those heuristics need, not only their alerts (+3.4% packets per second with both muted, on a UDP-heavy mix).
- The beaconing case of the replay corpus asserts its detection again. Its expectation was kept for the retired Python parity harness only, so from 27 Aug nothing would have noticed beaconing breaking.
- A scan (port, UDP, web, infection) or ICMP tunnel in the last second of a replayed capture is reported. The heuristics report on the next packet's arrival, and at the end of the file none arrived.
- A scanner listed by a feed lost its "potential port scanning" verdict: both share the trail (the scanner's address), and were condensed/throttled into one line carrying only the feed's info (#782)
- The dashboard opens on the sensor's "today", not the viewer's; a viewer a day ahead saw an empty day (#67)
- BLACKLIST / BLACKLIST_ no longer warn as unknown options at startup; their syntax is now documented in maltrail.conf (#19610)
- Trail gate: a truncated IP (
http://144.172.84), a name under no TLD (minglu.de5) and a pasted header (# Reference: # Reference:) fail it (#19630, #19631, #19624)
Removed
- The bitcoinnodes feed: its upstream has been frozen since 1 Jan 2026, and a reachable Bitcoin node is not bad reputation (#19659).
- The long domain heuristic (a first label over 24 characters). On live traffic every event but one was a CDN or device-telemetry hash (a2z.com, aws.dev, max.com, googleapis.com), and each fix was one more exempt zone. Tunneling is left to dns_tunneling, which needs volume and a session; 'long_domain' is still accepted in DISABLED_HEURISTICS.
- The abuse.ch JA3 feed: last updated 2021-08-03, 97 fingerprints of TLS libraries first seen in 2017-2020. Live, 41 events in 40 hours, none real: 'tofsee' was inbound scanners, 'coinminer' an Apple device fetching mediaservices.cdn-apple.com. The certificate feed stays.
- Code only the retired Python sensor used: core/log.py's event writer and condenser (log_event, flush_condensed_events) and core/meta.py's writer and pruner. The server keeps everything it calls (the auth events' syslog/logstash forwarding, severity_of, the /meta reader).
- The per-day event index (USE_EVENT_INDEX, LOG_DIR/index/, server.py --rebuild-index) is gone, and its files are removed on start. It took ~10 s and twice the log's size per day to build, and opening the date picker could build ~120 days in one request. /counts now counts lines (44 ms per 55 MB day, exact); /hunt parses only lines that can contain the query (0.17 s per 55 MB day instead of 6.2 s).
- USE_FAST_PREFILTER, FAST_FLOW_CUTOFF, FAST_ADMIT_LEVEL and FAST_ADMIT_ADAPTIVE are gone. They configured the retired Python sensor's C prefilter; all that was left of them was switching SNI matching on, which is now CHECK_TLS_SNI and on by default. With DNS over HTTPS the SNI is where the domain is still visible, and the handshake was already parsed for JA3/JA4, so it costs one lookup per connection.
Full Changelog: 3.5...3.6